wip: refactoring to knex and general chores, starting with User

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2025-03-14 23:33:29 +01:00
parent 6e151c8a1b
commit 7adce05412
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
198 changed files with 3865 additions and 5899 deletions

View file

@ -50,12 +50,13 @@
"express-session": "1.18.1", "express-session": "1.18.1",
"file-type": "16.5.4", "file-type": "16.5.4",
"htmlparser2": "9.1.0", "htmlparser2": "9.1.0",
"keyv": "^5.3.2",
"knex": "3.1.0", "knex": "3.1.0",
"ldapauth-fork": "6.1.0", "ldapauth-fork": "6.1.0",
"markdown-it": "13.0.2", "markdown-it": "13.0.2",
"minio": "8.0.4", "minio": "8.0.4",
"mysql": "2.18.1", "mysql": "2.18.1",
"nestjs-knex": "2.0.0", "nest-knexjs": "0.0.26",
"nestjs-zod": "4.3.1", "nestjs-zod": "4.3.1",
"node-fetch": "2.7.0", "node-fetch": "2.7.0",
"openid-client": "5.7.1", "openid-client": "5.7.1",

View file

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

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Mock } from 'ts-mockery'; 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 appConfigMock from '../config/mock/app.config.mock';
import authConfigMock from '../config/mock/auth.config.mock'; import authConfigMock from '../config/mock/auth.config.mock';
import databaseConfigMock from '../config/mock/database.config.mock'; import databaseConfigMock from '../config/mock/database.config.mock';
import noteConfigMock from '../config/mock/note.config.mock'; import noteConfigMock from '../config/mock/note.config.mock';
import { User } from '../database/user.entity';
import { import {
AlreadyInDBError, AlreadyInDBError,
ForbiddenIdError, ForbiddenIdError,
@ -25,24 +19,14 @@ import {
PrimaryAliasDeletionForbiddenError, PrimaryAliasDeletionForbiddenError,
} from '../errors/errors'; } from '../errors/errors';
import { eventModuleConfig } from '../events'; import { eventModuleConfig } from '../events';
import { Group } from '../groups/group.entity';
import { GroupsModule } from '../groups/groups.module'; import { GroupsModule } from '../groups/groups.module';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteService } from '../notes/note.service';
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module'; 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 { RevisionsModule } from '../revisions/revisions.module';
import { Session } from '../sessions/session.entity';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; import { AliasModule } from './alias.module';
import { Alias } from './alias.entity';
import { AliasService } from './alias.service'; 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', () => { describe('AliasService', () => {
let service: AliasService; let service: AliasService;
@ -73,7 +57,7 @@ describe('AliasService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
AliasService, AliasService,
NotesService, NoteService,
{ {
provide: getRepositoryToken(Note), provide: getRepositoryToken(Note),
useValue: noteRepo, useValue: noteRepo,
@ -105,7 +89,7 @@ describe('AliasService', () => {
UsersModule, UsersModule,
GroupsModule, GroupsModule,
RevisionsModule, RevisionsModule,
NotesModule, AliasModule,
RealtimeNoteModule, RealtimeNoteModule,
EventEmitterModule.forRoot(eventModuleConfig), EventEmitterModule.forRoot(eventModuleConfig),
], ],
@ -149,7 +133,7 @@ describe('AliasService', () => {
const alias2 = 'testAlias2'; const alias2 = 'testAlias2';
const user = User.create('hardcoded', 'Testy') as User; const user = User.create('hardcoded', 'Testy') as User;
describe('creates', () => { 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; const note = Note.create(user) as Note;
jest jest
.spyOn(noteRepo, 'save') .spyOn(noteRepo, 'save')
@ -160,7 +144,7 @@ describe('AliasService', () => {
expect(savedAlias.name).toEqual(alias); expect(savedAlias.name).toEqual(alias);
expect(savedAlias.primary).toBeTruthy(); 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; const note = Note.create(user, alias) as Note;
jest jest
.spyOn(noteRepo, 'save') .spyOn(noteRepo, 'save')
@ -172,7 +156,7 @@ describe('AliasService', () => {
expect(savedAlias.primary).toBeFalsy(); expect(savedAlias.primary).toBeFalsy();
}); });
}); });
describe('does not create an alias', () => { describe('does not create an aliases', () => {
const note = Note.create(user, alias2) as Note; const note = Note.create(user, alias2) as Note;
it('with an already used name', async () => { it('with an already used name', async () => {
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false);
@ -193,7 +177,7 @@ describe('AliasService', () => {
const alias = 'testAlias'; const alias = 'testAlias';
const alias2 = 'testAlias2'; const alias2 = 'testAlias2';
const user = User.create('hardcoded', 'Testy') as User; const user = User.create('hardcoded', 'Testy') as User;
describe('removes one alias correctly', () => { describe('removes one aliases correctly', () => {
let note: Note; let note: Note;
beforeAll(async () => { beforeAll(async () => {
note = Note.create(user, alias) as Note; note = Note.create(user, alias) as Note;
@ -214,7 +198,7 @@ describe('AliasService', () => {
expect(aliases[0].name).toEqual(alias); expect(aliases[0].name).toEqual(alias);
expect(aliases[0].primary).toBeTruthy(); expect(aliases[0].primary).toBeTruthy();
}); });
it('with one alias, that is primary', async () => { it('with one aliases, that is primary', async () => {
jest jest
.spyOn(noteRepo, 'save') .spyOn(noteRepo, 'save')
.mockImplementationOnce(async (note: Note): Promise<Note> => note); .mockImplementationOnce(async (note: Note): Promise<Note> => note);
@ -227,13 +211,13 @@ describe('AliasService', () => {
expect(await savedNote.aliases).toHaveLength(0); expect(await savedNote.aliases).toHaveLength(0);
}); });
}); });
describe('does not remove one alias', () => { describe('does not remove one aliases', () => {
let note: Note; let note: Note;
beforeEach(async () => { beforeEach(async () => {
note = Note.create(user, alias) as Note; note = Note.create(user, alias) as Note;
(await note.aliases).push(Alias.create(alias2, note, false) as Alias); (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( await expect(service.removeAlias(note, 'non existent')).rejects.toThrow(
NotInDBError, NotInDBError,
); );
@ -261,7 +245,7 @@ describe('AliasService', () => {
); );
}); });
it('mark the alias as primary', async () => { it('mark the aliases as primary', async () => {
jest jest
.spyOn(aliasRepo, 'findOneByOrFail') .spyOn(aliasRepo, 'findOneByOrFail')
.mockResolvedValueOnce(alias) .mockResolvedValueOnce(alias)
@ -293,7 +277,7 @@ describe('AliasService', () => {
expect(savedAlias.name).toEqual(alias2.name); expect(savedAlias.name).toEqual(alias2.name);
expect(savedAlias.primary).toBeTruthy(); 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( await expect(
service.makeAliasPrimary(note, 'i_dont_exist'), service.makeAliasPrimary(note, 'i_dont_exist'),
).rejects.toThrow(NotInDBError); ).rejects.toThrow(NotInDBError);

View file

@ -0,0 +1,276 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AliasDto } from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common';
import base32Encode from 'base32-encode';
import { randomBytes } from 'crypto';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import {
Alias,
FieldNameAlias,
FieldNameNote,
Note,
TableAlias,
} from '../database/types';
import { TypeInsertAlias } from '../database/types/alias';
import {
AlreadyInDBError,
ForbiddenIdError,
GenericDBError,
NotInDBError,
PrimaryAliasDeletionForbiddenError,
} from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
@Injectable()
export class AliasService {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectConnection()
private readonly knex: Knex,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
) {
this.logger.setContext(AliasService.name);
}
/**
* Generates a random alias for a note.
* This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant
* and converted to lowercase.
*
* @return The randomly generated alias
*/
generateRandomAlias(): string {
const randomId = randomBytes(16);
return base32Encode(randomId, 'Crockford').toLowerCase();
}
/**
* Adds the specified alias to the note
*
* @param noteId The id of the note to add the aliases to
* @param alias The alias to add to the note
* @param transaction The optional transaction to access the db
* @throws {AlreadyInDBError} The alias is already in use.
* @throws {ForbiddenIdError} The requested alias is forbidden
*/
async addAlias(
noteId: Note[FieldNameNote.id],
alias: Alias[FieldNameAlias.alias],
transaction?: Knex,
): Promise<void> {
const dbActor: Knex = transaction ? transaction : this.knex;
const newAlias: TypeInsertAlias = {
[FieldNameAlias.alias]: alias,
[FieldNameAlias.noteId]: noteId,
[FieldNameAlias.isPrimary]: false,
};
const oldAliases = await dbActor(TableAlias)
.select(FieldNameAlias.alias)
.where(FieldNameAlias.noteId, noteId);
if (oldAliases.length === 0) {
// The first alias is automatically made the primary aliases
newAlias[FieldNameAlias.isPrimary] = true;
}
await dbActor(TableAlias).insert(newAlias);
}
/**
* Makes the specified alias the primary alias of the note
*
* @param noteId The id of the note to change the primary alias
* @param alias The alias to be the new primary alias of the note
* @throws {ForbiddenIdError} The requested alias is forbidden
* @throws {NotInDBError} The alias is not assigned to this note
*/
async makeAliasPrimary(
noteId: Note[FieldNameNote.id],
alias: Alias[FieldNameAlias.alias],
): Promise<void> {
await this.knex.transaction(async (transaction) => {
// First set all existing aliases to not primary
const numberOfUpdatedEntries = await transaction(TableAlias)
.update(FieldNameAlias.isPrimary, null)
.where(FieldNameAlias.noteId, noteId);
if (numberOfUpdatedEntries === 0) {
throw new GenericDBError(
`The note does not exists or has no primary alias. This should never happen`,
this.logger.getContext(),
'makeAliasPrimary',
);
}
// Then set the specified alias to primary
const numberOfUpdatedPrimaryAliases = await transaction(TableAlias)
.update(FieldNameAlias.isPrimary, true)
.where(FieldNameAlias.noteId, noteId)
.andWhere(FieldNameAlias.alias, alias);
if (numberOfUpdatedPrimaryAliases !== 1) {
throw new NotInDBError(
`The alias '${alias}' is not used by this note.`,
this.logger.getContext(),
'makeAliasPrimary',
);
}
});
}
/**
* Removes the specified alias from the note
* This method only does not require the noteId since it can be obtained from the alias prior to deletion
*
* @param alias The alias to remove from the note
* @throws {ForbiddenIdError} The requested alias is forbidden
* @throws {NotInDBError} The alias is not assigned to this note
* @throws {PrimaryAliasDeletionForbiddenError} The primary alias cannot be deleted
*/
async removeAlias(alias: Alias[FieldNameAlias.alias]): Promise<void> {
await this.knex.transaction(async (transaction) => {
const aliases = await transaction(TableAlias)
.select()
.where(FieldNameAlias.alias, alias);
if (aliases.length !== 1) {
throw new NotInDBError(
`The alias '${alias}' does not exist.`,
this.logger.getContext(),
'removeAlias',
);
}
const noteId = aliases[0][FieldNameAlias.noteId];
const numberOfDeletedAliases = await transaction(TableAlias)
.where(FieldNameAlias.alias, alias)
.andWhere(FieldNameAlias.noteId, noteId)
.andWhere(FieldNameAlias.isPrimary, null)
.delete();
if (numberOfDeletedAliases !== 0) {
throw new PrimaryAliasDeletionForbiddenError(
`The alias '${alias}' is the primary alias, which can not be removed.`,
this.logger.getContext(),
'removeAlias',
);
}
});
}
/**
* Get the primaryAlias of the note specifed by the noteId.
* @param noteId The id of the note to get the primary alias of
* @throws {NotInDBError} The note has no primary alias.
* @return primary alias of the note.
*/
async getPrimaryAliasByNoteId(
noteId: number,
): Promise<Alias[FieldNameAlias.alias]> {
const aliases = await this.knex(TableAlias)
.select(FieldNameAlias.alias)
.where(FieldNameAlias.noteId, noteId)
.andWhere(FieldNameAlias.isPrimary, true);
if (aliases.length !== 1) {
throw new NotInDBError(
`The noteId '${noteId}' has no primary alias.`,
this.logger.getContext(),
'removeAlias',
);
}
return aliases[0][FieldNameAlias.alias];
}
/**
* Checks if the provided alias is available for notes
* This method does not return any value but throws an error if it is not successful
*
* @param alias The alias to check
* @param transaction The optional transaction to access the db
* @throws {ForbiddenIdError} The requested alias is not available
* @throws {AlreadyInDBError} The requested alias already exists
*/
async ensureAliasIsAvailable(
alias: Alias[FieldNameAlias.alias],
transaction?: Knex,
): Promise<void> {
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<boolean> {
const dbActor = transaction ? transaction : this.knex;
const result = await dbActor(TableAlias)
.select(FieldNameAlias.alias)
.where(FieldNameAlias.alias, alias);
if (result.length === 1) {
this.logger.warn(
`A note with the id or alias '${alias}' already exists.`,
'isAliasUsed',
);
return true;
}
return false;
}
/**
* Build the AliasDto from a note.
* @param alias The alias to use
* @param isPrimaryAlias If the alias is the primary alias.
* @throws {NotInDBError} The specified alias does not exist
* @return {AliasDto} The built AliasDto
*/
toAliasDto(alias: string, isPrimaryAlias: boolean): AliasDto {
return {
name: alias,
isPrimaryAlias: isPrimaryAlias,
};
}
}

View file

@ -3,6 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { AuthProviderType } from '@hedgedoc/commons';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { CompleteRequest } from '../api/utils/request.type'; import { CompleteRequest } from '../api/utils/request.type';
@ -30,7 +31,10 @@ export class ApiTokenGuard implements CanActivate {
return false; return false;
} }
try { try {
request.user = await this.apiTokenService.validateToken(token.trim()); request.userId = await this.apiTokenService.getUserIdForToken(
token.trim(),
);
request.authProviderType = AuthProviderType.TOKEN;
return true; return true;
} catch (error) { } catch (error) {
if ( if (

View file

@ -4,17 +4,16 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { KnexModule } from 'nest-knexjs';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { ApiToken } from './api-token.entity';
import { ApiTokenGuard } from './api-token.guard'; import { ApiTokenGuard } from './api-token.guard';
import { ApiTokenService } from './api-token.service'; import { ApiTokenService } from './api-token.service';
import { MockApiTokenGuard } from './mock-api-token.guard'; import { MockApiTokenGuard } from './mock-api-token.guard';
@Module({ @Module({
imports: [UsersModule, LoggerModule, TypeOrmModule.forFeature([ApiToken])], imports: [UsersModule, LoggerModule, KnexModule],
providers: [ApiTokenService, ApiTokenGuard, MockApiTokenGuard], providers: [ApiTokenService, ApiTokenGuard, MockApiTokenGuard],
exports: [ApiTokenService, ApiTokenGuard], exports: [ApiTokenService, ApiTokenGuard],
}) })

View file

@ -104,13 +104,13 @@ describe('ApiTokenService', () => {
describe('getTokensByUser', () => { describe('getTokensByUser', () => {
it('works', async () => { it('works', async () => {
createQueryBuilderFunc.getMany = () => [apiToken]; createQueryBuilderFunc.getMany = () => [apiToken];
const tokens = await service.getTokensByUser(user); const tokens = await service.getTokensOfUserById(user);
expect(tokens).toHaveLength(1); expect(tokens).toHaveLength(1);
expect(tokens).toEqual([apiToken]); expect(tokens).toEqual([apiToken]);
}); });
it('should return empty array if token for user do not exists', async () => { it('should return empty array if token for user do not exists', async () => {
jest.spyOn(apiTokenRepo, 'find').mockImplementationOnce(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).toHaveLength(0);
expect(tokens).toEqual([]); expect(tokens).toEqual([]);
}); });
@ -153,13 +153,13 @@ describe('ApiTokenService', () => {
); );
expect(() => expect(() =>
service.checkToken(secret, accessToken as ApiToken), service.ensureTokenIsValid(secret, accessToken as ApiToken),
).not.toThrow(); ).not.toThrow();
}); });
it('AuthToken has wrong hash', () => { it('AuthToken has wrong hash', () => {
const [accessToken] = service.createToken(user, 'TestToken', null); const [accessToken] = service.createToken(user, 'TestToken', null);
expect(() => expect(() =>
service.checkToken('secret', accessToken as ApiToken), service.ensureTokenIsValid('secret', accessToken as ApiToken),
).toThrow(TokenNotValidError); ).toThrow(TokenNotValidError);
}); });
it('AuthToken has wrong validUntil Date', () => { it('AuthToken has wrong validUntil Date', () => {
@ -168,9 +168,9 @@ describe('ApiTokenService', () => {
'Test', 'Test',
new Date(1549312452000), new Date(1549312452000),
); );
expect(() => service.checkToken(secret, accessToken as ApiToken)).toThrow( expect(() =>
TokenNotValidError, service.ensureTokenIsValid(secret, accessToken as ApiToken),
); ).toThrow(TokenNotValidError);
}); });
}); });
@ -222,7 +222,7 @@ describe('ApiTokenService', () => {
.mockImplementationOnce(async (_, __): Promise<ApiToken> => { .mockImplementationOnce(async (_, __): Promise<ApiToken> => {
return apiToken; return apiToken;
}); });
const userByToken = await service.validateToken( const userByToken = await service.getUserIdForToken(
`hd2.${apiToken.keyId}.${testSecret}`, `hd2.${apiToken.keyId}.${testSecret}`,
); );
expect(userByToken).toEqual({ expect(userByToken).toEqual({
@ -233,27 +233,27 @@ describe('ApiTokenService', () => {
describe('fails:', () => { describe('fails:', () => {
it('the prefix is missing', async () => { it('the prefix is missing', async () => {
await expect( await expect(
service.validateToken(`${apiToken.keyId}.${'a'.repeat(73)}`), service.getUserIdForToken(`${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError); ).rejects.toThrow(TokenNotValidError);
}); });
it('the prefix is wrong', async () => { it('the prefix is wrong', async () => {
await expect( await expect(
service.validateToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`), service.getUserIdForToken(`hd1.${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError); ).rejects.toThrow(TokenNotValidError);
}); });
it('the secret is missing', async () => { it('the secret is missing', async () => {
await expect( await expect(
service.validateToken(`hd2.${apiToken.keyId}`), service.getUserIdForToken(`hd2.${apiToken.keyId}`),
).rejects.toThrow(TokenNotValidError); ).rejects.toThrow(TokenNotValidError);
}); });
it('the secret is too long', async () => { it('the secret is too long', async () => {
await expect( await expect(
service.validateToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`), service.getUserIdForToken(`hd2.${apiToken.keyId}.${'a'.repeat(73)}`),
).rejects.toThrow(TokenNotValidError); ).rejects.toThrow(TokenNotValidError);
}); });
it('the token contains sections after the secret', async () => { it('the token contains sections after the secret', async () => {
await expect( await expect(
service.validateToken( service.getUserIdForToken(
`hd2.${apiToken.keyId}.${'a'.repeat(73)}.extra`, `hd2.${apiToken.keyId}.${'a'.repeat(73)}.extra`,
), ),
).rejects.toThrow(TokenNotValidError); ).rejects.toThrow(TokenNotValidError);

View file

@ -6,19 +6,29 @@
import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons'; import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule'; import { Cron, Timeout } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { randomBytes } from 'crypto';
import { createHash, randomBytes, timingSafeEqual } from 'crypto'; import { Knex } from 'knex';
import { Repository } from 'typeorm'; import { InjectConnection } from 'nest-knexjs';
import { User } from '../database/user.entity'; import {
ApiToken,
FieldNameApiToken,
FieldNameUser,
TableApiToken, TableUser,
User,
} from '../database/types';
import { TypeInsertApiToken } from '../database/types/api-token';
import { import {
NotInDBError, NotInDBError,
TokenNotValidError, TokenNotValidError,
TooManyTokensError, TooManyTokensError,
} from '../errors/errors'; } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { bufferToBase64Url, checkTokenEquality } from '../utils/password'; import {
import { ApiToken } from './api-token.entity'; bufferToBase64Url,
checkTokenEquality,
hashApiToken,
} from '../utils/password';
export const AUTH_TOKEN_PREFIX = 'hd2'; export const AUTH_TOKEN_PREFIX = 'hd2';
@ -26,13 +36,22 @@ export const AUTH_TOKEN_PREFIX = 'hd2';
export class ApiTokenService { export class ApiTokenService {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
@InjectRepository(ApiToken)
private authTokenRepository: Repository<ApiToken>, @InjectConnection()
private readonly knex: Knex,
) { ) {
this.logger.setContext(ApiTokenService.name); this.logger.setContext(ApiTokenService.name);
} }
async validateToken(tokenString: string): Promise<User> { /**
* 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<number> {
const [prefix, keyId, secret, ...rest] = tokenString.split('.'); const [prefix, keyId, secret, ...rest] = tokenString.split('.');
if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) { if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) {
throw new TokenNotValidError('Invalid API token format'); throw new TokenNotValidError('Invalid API token format');
@ -44,21 +63,60 @@ export class ApiTokenService {
`API token '${tokenString}' has incorrect length`, `API token '${tokenString}' has incorrect length`,
); );
} }
const token = await this.getToken(keyId); return await this.knex.transaction(async (transaction) => {
this.checkToken(secret, token); const token = await transaction(TableApiToken)
await this.setLastUsedToken(keyId); .select(
return token.user; 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];
});
}
/**
* 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<ApiTokenWithSecretDto> {
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`,
);
} }
createToken(
user: User,
identifier: string,
userDefinedValidUntil: Date | null,
): [Omit<ApiToken, 'id' | 'createdAt'>, string] {
const secret = bufferToBase64Url(randomBytes(64)); const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8)); const keyId = bufferToBase64Url(randomBytes(8));
// More about the choice of SHA-512 in the dev docs const accessTokenHash = hashApiToken(secret);
const accessTokenHash = createHash('sha512').update(secret).digest('hex');
// Tokens can only be valid for a maximum of 2 years // Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date(); const maximumTokenValidity = new Date();
maximumTokenValidity.setTime( maximumTokenValidity.setTime(
@ -69,154 +127,138 @@ export class ApiTokenService {
const validUntil = isTokenLimitedToMaximumValidity const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity ? maximumTokenValidity
: userDefinedValidUntil; : userDefinedValidUntil;
const token = ApiToken.create( const token: TypeInsertApiToken = {
keyId, [FieldNameApiToken.id]: keyId,
user, [FieldNameApiToken.label]: tokenLabel,
identifier, [FieldNameApiToken.userId]: userId,
accessTokenHash, [FieldNameApiToken.secretHash]: accessTokenHash,
new Date(validUntil), [FieldNameApiToken.validUntil]: validUntil,
); [FieldNameApiToken.createdAt]: new Date(),
return [token, secret];
}
async addToken(
user: User,
identifier: string,
validUntil: Date | null,
): Promise<ApiTokenWithSecretDto> {
user.apiTokens = this.getTokensByUser(user);
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 [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<void> {
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<ApiToken> {
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
throw new TokenNotValidError(
`Secret does not match Token ${token.label}.`,
);
}
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()}.`,
);
}
}
async getTokensByUser(user: User): Promise<ApiToken[]> {
const tokens = await this.authTokenRepository.find({
where: { user: { id: user.id } },
});
if (tokens === null) {
return [];
}
return tokens;
}
async removeToken(keyId: string): Promise<void> {
const token = await this.authTokenRepository.findOne({
where: { keyId: keyId },
});
if (token === null) {
throw new NotInDBError(`API token with id '${keyId}' 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,
}; };
await this.knex(TableApiToken).insert(token);
if (authToken.lastUsedAt) { return this.toAuthTokenWithSecretDto(
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt).toISOString(); {
...token,
[FieldNameApiToken.lastUsedAt]: null,
},
secret,
);
});
} }
return tokenDto; /**
* 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(
`Auth token is not valid since ${validUntil.toISOString()}`,
);
} }
// Second, verify the secret (costly operation)
if (!checkTokenEquality(secret, tokenHash)) {
throw new TokenNotValidError(`Secret does not match token hash`);
}
}
/**
* 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<ApiToken[]> {
return this.knex(TableApiToken)
.select()
.where(FieldNameApiToken.userId, userId);
}
/**
* 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<void> {
const numberOfDeletedTokens = await this.knex(TableApiToken)
.where(FieldNameApiToken.id, keyId)
.andWhere(FieldNameApiToken.userId, userId)
.delete();
if (numberOfDeletedTokens === 0) {
throw new NotInDBError('Token not found');
}
}
/**
* 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,
};
}
/**
* Converts an ApiToken to an ApiTokenWithSecretDto
*
* @param apiToken The token to convert
* @param secret The secret of the token
* @return The converted token
*/
toAuthTokenWithSecretDto( toAuthTokenWithSecretDto(
authToken: ApiToken, apiToken: ApiToken,
secret: string, secret: string,
): ApiTokenWithSecretDto { ): ApiTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(authToken); const tokenDto = this.toAuthTokenDto(apiToken);
const fullToken = `${AUTH_TOKEN_PREFIX}.${tokenDto.keyId}.${secret}`;
return { return {
...tokenDto, ...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') @Cron('0 0 3 * * 0')
async handleCron(): Promise<void> { async handleCron(): Promise<void> {
return await this.removeInvalidTokens(); return await this.removeInvalidTokens();
} }
// Delete all non valid tokens 5 sec after startup // Delete all invalid tokens 5 sec after startup
@Timeout(5000) @Timeout(5000)
async handleTimeout(): Promise<void> { async handleTimeout(): Promise<void> {
return await this.removeInvalidTokens(); 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<void> { async removeInvalidTokens(): Promise<void> {
const currentTime = new Date().getTime(); const numberOfDeletedTokens = await this.knex(TableApiToken)
const tokens: ApiToken[] = await this.authTokenRepository.find(); .where(FieldNameApiToken.validUntil, '<', new Date())
let removedTokens = 0; .delete();
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++;
}
}
this.logger.log( this.logger.log(
`${removedTokens} invalid AuthTokens were purged from the DB.`, `${numberOfDeletedTokens} invalid AuthTokens were purged from the DB.`,
'removeInvalidTokens', 'removeInvalidTokens',
); );
} }

View file

@ -5,7 +5,6 @@
*/ */
import { AliasCreateDto } from '@hedgedoc/commons'; import { AliasCreateDto } from '@hedgedoc/commons';
import { AliasUpdateDto } from '@hedgedoc/commons'; import { AliasUpdateDto } from '@hedgedoc/commons';
import { AliasDto } from '@hedgedoc/commons';
import { import {
BadRequestException, BadRequestException,
Body, Body,
@ -19,15 +18,13 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AliasService } from '../../../alias/alias.service';
import { SessionGuard } from '../../../auth/session.guard'; import { SessionGuard } from '../../../auth/session.guard';
import { User } from '../../../database/user.entity';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { AliasService } from '../../../notes/alias.service'; import { NoteService } from '../../../notes/note.service';
import { NotesService } from '../../../notes/notes.service'; import { PermissionService } from '../../../permissions/permission.service';
import { PermissionsService } from '../../../permissions/permissions.service'; import { RequestUserId } from '../../utils/decorators/request-user-id.decorator';
import { UsersService } from '../../../users/users.service';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestUser } from '../../utils/request-user.decorator';
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@OpenApi(401) @OpenApi(401)
@ -37,9 +34,8 @@ export class AliasController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private aliasService: AliasService, private aliasService: AliasService,
private noteService: NotesService, private noteService: NoteService,
private userService: UsersService, private permissionsService: PermissionService,
private permissionsService: PermissionsService,
) { ) {
this.logger.setContext(AliasController.name); this.logger.setContext(AliasController.name);
} }
@ -47,53 +43,53 @@ export class AliasController {
@Post() @Post()
@OpenApi(201, 400, 404, 409) @OpenApi(201, 400, 404, 409)
async addAlias( async addAlias(
@RequestUser() user: User, @RequestUserId() userId: number,
@Body() newAliasDto: AliasCreateDto, @Body() newAliasDto: AliasCreateDto,
): Promise<AliasDto> { ): Promise<void> {
const note = await this.noteService.getNoteByIdOrAlias( const noteId = await this.noteService.getNoteIdByAlias(
newAliasDto.noteIdOrAlias, newAliasDto.noteAlias,
); );
if (!(await this.permissionsService.isOwner(user, note))) { const isUserNoteOwner = await this.permissionsService.isOwner(
userId,
noteId,
);
if (!isUserNoteOwner) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }
const updatedAlias = await this.aliasService.addAlias( await this.aliasService.ensureAliasIsAvailable(newAliasDto.newAlias);
note, await this.aliasService.addAlias(noteId, newAliasDto.newAlias);
newAliasDto.newAlias,
);
return this.aliasService.toAliasDto(updatedAlias, note);
} }
@Put(':alias') @Put(':aliases')
@OpenApi(200, 400, 404) @OpenApi(200, 400, 404)
async makeAliasPrimary( async makeAliasPrimary(
@RequestUser() user: User, @RequestUserId() userId: number,
@Param('alias') alias: string, @Param('alias') alias: string,
@Body() changeAliasDto: AliasUpdateDto, @Body() changeAliasDto: AliasUpdateDto,
): Promise<AliasDto> { ): Promise<void> {
if (!changeAliasDto.primaryAlias) { if (!changeAliasDto.primaryAlias) {
throw new BadRequestException( throw new BadRequestException(
`The field 'primaryAlias' must be set to 'true'.`, `The field 'primaryAlias' must be set to 'true'.`,
); );
} }
const note = await this.noteService.getNoteByIdOrAlias(alias); const noteId = await this.noteService.getNoteIdByAlias(alias);
if (!(await this.permissionsService.isOwner(user, note))) { if (!(await this.permissionsService.isOwner(userId, noteId))) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }
const updatedAlias = await this.aliasService.makeAliasPrimary(note, alias); await this.aliasService.makeAliasPrimary(noteId, alias);
return this.aliasService.toAliasDto(updatedAlias, note);
} }
@Delete(':alias') @Delete(':aliases')
@OpenApi(204, 400, 404) @OpenApi(204, 400, 404)
async removeAlias( async removeAlias(
@RequestUser() user: User, @RequestUserId() userId: number,
@Param('alias') alias: string, @Param('alias') alias: string,
): Promise<void> { ): Promise<void> {
const note = await this.noteService.getNoteByIdOrAlias(alias); const note = await this.noteService.getNoteIdByAlias(alias);
if (!(await this.permissionsService.isOwner(user, note))) { if (!(await this.permissionsService.isOwner(userId, note))) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }
await this.aliasService.removeAlias(note, alias); await this.aliasService.removeAlias(alias);
return; return;
} }
} }

View file

@ -15,17 +15,16 @@ import {
Get, Get,
Param, Param,
Post, Post,
UnauthorizedException,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ApiTokenService } from '../../../api-token/api-token.service'; import { ApiTokenService } from '../../../api-token/api-token.service';
import { SessionGuard } from '../../../auth/session.guard'; 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 { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { RequestUserInfo } from '../../utils/request-user-id.decorator';
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@OpenApi(401) @OpenApi(401)
@ -41,8 +40,8 @@ export class ApiTokensController {
@Get() @Get()
@OpenApi(200) @OpenApi(200)
async getUserTokens(@RequestUser() user: User): Promise<ApiTokenDto[]> { async getUserTokens(@RequestUserInfo() userId: number): Promise<ApiTokenDto[]> {
return (await this.publicAuthTokenService.getTokensByUser(user)).map( return (await this.publicAuthTokenService.getTokensOfUserById(userId)).map(
(token) => this.publicAuthTokenService.toAuthTokenDto(token), (token) => this.publicAuthTokenService.toAuthTokenDto(token),
); );
} }
@ -51,10 +50,10 @@ export class ApiTokensController {
@OpenApi(201) @OpenApi(201)
async postTokenRequest( async postTokenRequest(
@Body() createDto: ApiTokenCreateDto, @Body() createDto: ApiTokenCreateDto,
@RequestUser() user: User, @RequestUserInfo() userId: User[FieldNameUser.id],
): Promise<ApiTokenWithSecretDto> { ): Promise<ApiTokenWithSecretDto> {
return await this.publicAuthTokenService.addToken( return await this.publicAuthTokenService.createToken(
user, userId,
createDto.label, createDto.label,
createDto.validUntil, createDto.validUntil,
); );
@ -63,17 +62,9 @@ export class ApiTokensController {
@Delete('/:keyId') @Delete('/:keyId')
@OpenApi(204, 404) @OpenApi(204, 404)
async deleteToken( async deleteToken(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@Param('keyId') keyId: string, @Param('keyId') keyId: string,
): Promise<void> { ): Promise<void> {
const tokens = await this.publicAuthTokenService.getTokensByUser(user); await this.publicAuthTokenService.removeToken(keyId, userId);
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',
);
} }
} }

View file

@ -23,9 +23,10 @@ import { ApiTags } from '@nestjs/swagger';
import { IdentityService } from '../../../auth/identity.service'; import { IdentityService } from '../../../auth/identity.service';
import { OidcService } from '../../../auth/oidc/oidc.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 { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestWithSession } from '../../utils/request.type';
@ApiTags('auth') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@ -63,7 +64,9 @@ export class AuthController {
@Get('pending-user') @Get('pending-user')
@OpenApi(200, 400) @OpenApi(200, 400)
getPendingUserData(@Req() request: RequestWithSession): FullUserInfoDto { getPendingUserData(
@Req() request: RequestWithSession,
): Partial<FullUserInfoDto> {
if ( if (
!request.session.newUserData || !request.session.newUserData ||
!request.session.authProviderIdentifier || !request.session.authProviderIdentifier ||
@ -78,7 +81,7 @@ export class AuthController {
@OpenApi(204, 400) @OpenApi(204, 400)
async confirmPendingUserData( async confirmPendingUserData(
@Req() request: RequestWithSession, @Req() request: RequestWithSession,
@Body() updatedUserInfo: PendingUserConfirmationDto, @Body() pendingUserConfirmationData: PendingUserConfirmationDto,
): Promise<void> { ): Promise<void> {
if ( if (
!request.session.newUserData || !request.session.newUserData ||
@ -88,9 +91,10 @@ export class AuthController {
) { ) {
throw new BadRequestException('No pending user data'); throw new BadRequestException('No pending user data');
} }
const identity = await this.identityService.createUserWithIdentity( const identity =
await this.identityService.createUserWithIdentityFromPendingUserConfirmation(
request.session.newUserData, request.session.newUserData,
updatedUserInfo, pendingUserConfirmationData,
request.session.authProviderType, request.session.authProviderType,
request.session.authProviderIdentifier, request.session.authProviderIdentifier,
request.session.providerUserId, request.session.providerUserId,

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
AuthProviderType,
GuestLoginDto,
GuestRegistrationResponseDto,
} from '@hedgedoc/commons';
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { IdentityService } from '../../../../auth/identity.service';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { UsersService } from '../../../../users/users.service';
import { OpenApi } from '../../../utils/decorators/openapi.decorator';
import { GuestsEnabledGuard } from '../../../utils/guards/guests-enabled.guard';
import { RequestWithSession } from '../../../utils/request.type';
@ApiTags('auth')
@Controller('/auth/guest')
export class GuestController {
constructor(
private readonly logger: ConsoleLoggerService,
private usersService: UsersService,
private identityService: IdentityService,
) {
this.logger.setContext(GuestController.name);
}
@UseGuards(GuestsEnabledGuard)
@Post('register')
@OpenApi(201, 403)
async registerGuestUser(
@Req() request: RequestWithSession,
): Promise<GuestRegistrationResponseDto> {
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<void> {
const userId = await this.usersService.getUserIdByGuestUuid(loginDto.uuid);
request.session.authProviderType = AuthProviderType.GUEST;
request.session.userId = userId;
}
}

View file

@ -66,7 +66,7 @@ export class LdapController {
loginDto.username.toLowerCase(), loginDto.username.toLowerCase(),
); );
await this.usersService.updateUser( await this.usersService.updateUser(
user, makeUsernameLowercase(loginDto.username),
userInfo.displayName, userInfo.displayName,
userInfo.email, userInfo.email,
userInfo.photoUrl, userInfo.photoUrl,

View file

@ -25,13 +25,14 @@ import {
RequestWithSession, RequestWithSession,
SessionGuard, SessionGuard,
} from '../../../../auth/session.guard'; } from '../../../../auth/session.guard';
import { User } from '../../../../database/user.entity'; import { FieldNameIdentity, FieldNameUser } from '../../../../database/types';
import { InvalidCredentialsError } from '../../../../errors/errors';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { UsersService } from '../../../../users/users.service'; import { UsersService } from '../../../../users/users.service';
import { LoginEnabledGuard } from '../../../utils/login-enabled.guard'; import { LoginEnabledGuard } from '../../../utils/login-enabled.guard';
import { OpenApi } from '../../../utils/openapi.decorator'; import { OpenApi } from '../../../utils/openapi.decorator';
import { RegistrationEnabledGuard } from '../../../utils/registration-enabled.guard'; import { RegistrationEnabledGuard } from '../../../utils/registration-enabled.guard';
import { RequestUser } from '../../../utils/request-user.decorator'; import { RequestUserId } from '../../../utils/request-user.decorator';
@ApiTags('auth') @ApiTags('auth')
@Controller('/auth/local') @Controller('/auth/local')
@ -52,34 +53,30 @@ export class LocalController {
@Body() registerDto: RegisterDto, @Body() registerDto: RegisterDto,
): Promise<void> { ): Promise<void> {
await this.localIdentityService.checkPasswordStrength(registerDto.password); await this.localIdentityService.checkPasswordStrength(registerDto.password);
const user = await this.usersService.createUser( const identity = await this.localIdentityService.createLocalIdentity(
registerDto.username, registerDto.username,
registerDto.displayName,
null,
null,
);
await this.localIdentityService.createLocalIdentity(
user,
registerDto.password, registerDto.password,
registerDto.displayName,
); );
// Log the user in after registration // Log the user in after registration
request.session.authProviderType = ProviderType.LOCAL; request.session.authProviderType = ProviderType.LOCAL;
request.session.username = registerDto.username; request.session.userId = identity[FieldNameIdentity.userId];
} }
@UseGuards(LoginEnabledGuard, SessionGuard) @UseGuards(LoginEnabledGuard, SessionGuard)
@Put() @Put()
@OpenApi(200, 400, 401) @OpenApi(200, 400, 401)
async updatePassword( async updatePassword(
@RequestUser() user: User, @RequestUserId() userId: number,
@Body() changePasswordDto: UpdatePasswordDto, @Body() changePasswordDto: UpdatePasswordDto,
): Promise<void> { ): Promise<void> {
const user = await this.usersService.getUserById(userId);
await this.localIdentityService.checkLocalPassword( await this.localIdentityService.checkLocalPassword(
user, user[FieldNameUser.username],
changePasswordDto.currentPassword, changePasswordDto.currentPassword,
); );
await this.localIdentityService.updateLocalPassword( await this.localIdentityService.updateLocalPassword(
user, userId,
changePasswordDto.newPassword, changePasswordDto.newPassword,
); );
} }
@ -93,15 +90,14 @@ export class LocalController {
@Body() loginDto: LoginDto, @Body() loginDto: LoginDto,
): Promise<void> { ): Promise<void> {
try { try {
const user = await this.usersService.getUserByUsername(loginDto.username); const identity = await this.localIdentityService.checkLocalPassword(
await this.localIdentityService.checkLocalPassword( loginDto.username,
user,
loginDto.password, loginDto.password,
); );
request.session.username = loginDto.username; request.session.userId = identity[FieldNameIdentity.userId];
request.session.authProviderType = ProviderType.LOCAL; request.session.authProviderType = ProviderType.LOCAL;
} catch (error) { } catch (error) {
this.logger.error(`Failed to log in user: ${String(error)}`); this.logger.info(`Failed to log in user: ${String(error)}`, 'login');
throw new UnauthorizedException('Invalid username or password'); throw new UnauthorizedException('Invalid username or password');
} }
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -23,10 +23,10 @@ import { HistoryEntryDto } from '../../../../history/history-entry.dto';
import { HistoryService } from '../../../../history/history.service'; import { HistoryService } from '../../../../history/history.service';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { Note } from '../../../../notes/note.entity'; import { Note } from '../../../../notes/note.entity';
import { GetNoteInterceptor } from '../../../utils/get-note.interceptor'; import { GetNoteIdInterceptor } from '../../../utils/get-note-id.interceptor';
import { OpenApi } from '../../../utils/openapi.decorator'; import { OpenApi } from '../../../utils/openapi.decorator';
import { RequestNote } from '../../../utils/request-note.decorator'; import { RequestNoteId } from '../../../utils/request-note-id.decorator';
import { RequestUser } from '../../../utils/request-user.decorator'; import { RequestUserId } from '../../../utils/request-user.decorator';
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@OpenApi(401) @OpenApi(401)
@ -42,7 +42,7 @@ export class HistoryController {
@Get() @Get()
@OpenApi(200, 404) @OpenApi(200, 404)
async getHistory(@RequestUser() user: User): Promise<HistoryEntryDto[]> { async getHistory(@RequestUserId() user: User): Promise<HistoryEntryDto[]> {
const foundEntries = await this.historyService.getEntriesByUser(user); const foundEntries = await this.historyService.getEntriesByUser(user);
return await Promise.all( return await Promise.all(
foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)), foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)),
@ -52,7 +52,7 @@ export class HistoryController {
@Post() @Post()
@OpenApi(201, 404) @OpenApi(201, 404)
async setHistory( async setHistory(
@RequestUser() user: User, @RequestUserId() user: User,
@Body() historyImport: HistoryEntryImportListDto, @Body() historyImport: HistoryEntryImportListDto,
): Promise<void> { ): Promise<void> {
await this.historyService.setHistory(user, historyImport.history); await this.historyService.setHistory(user, historyImport.history);
@ -60,16 +60,16 @@ export class HistoryController {
@Delete() @Delete()
@OpenApi(204, 404) @OpenApi(204, 404)
async deleteHistory(@RequestUser() user: User): Promise<void> { async deleteHistory(@RequestUserId() user: User): Promise<void> {
await this.historyService.deleteHistory(user); await this.historyService.deleteHistory(user);
} }
@Put(':noteIdOrAlias') @Put(':noteAlias')
@OpenApi(200, 404) @OpenApi(200, 404)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async updateHistoryEntry( async updateHistoryEntry(
@RequestNote() note: Note, @RequestNoteId() note: Note,
@RequestUser() user: User, @RequestUserId() user: User,
@Body() entryUpdateDto: HistoryEntryUpdateDto, @Body() entryUpdateDto: HistoryEntryUpdateDto,
): Promise<HistoryEntryDto> { ): Promise<HistoryEntryDto> {
const newEntry = await this.historyService.updateHistoryEntry( const newEntry = await this.historyService.updateHistoryEntry(
@ -80,12 +80,12 @@ export class HistoryController {
return await this.historyService.toHistoryEntryDto(newEntry); return await this.historyService.toHistoryEntryDto(newEntry);
} }
@Delete(':noteIdOrAlias') @Delete(':noteAlias')
@OpenApi(204, 404) @OpenApi(204, 404)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async deleteHistoryEntry( async deleteHistoryEntry(
@RequestNote() note: Note, @RequestNoteId() note: Note,
@RequestUser() user: User, @RequestUserId() user: User,
): Promise<void> { ): Promise<void> {
await this.historyService.deleteHistoryEntry(note, user); await this.historyService.deleteHistoryEntry(note, user);
} }

View file

@ -12,13 +12,11 @@ import { Body, Controller, Delete, Get, Put, UseGuards } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { SessionGuard } from '../../../auth/session.guard'; import { SessionGuard } from '../../../auth/session.guard';
import { User } from '../../../database/user.entity';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service'; import { UsersService } from '../../../users/users.service';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { RequestUserInfo } from '../../utils/request-user-id.decorator';
import { SessionAuthProvider } from '../../utils/session-authprovider.decorator'; import { SessionAuthProvider } from '../../utils/session-authprovider.decorator';
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@ -37,41 +35,42 @@ export class MeController {
@Get() @Get()
@OpenApi(200) @OpenApi(200)
getMe( getMe(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@SessionAuthProvider() authProvider: ProviderType, @SessionAuthProvider() authProvider: ProviderType,
): LoginUserInfoDto { ): LoginUserInfoDto {
return this.userService.toLoginUserInfoDto(user, authProvider); return this.userService.toLoginUserInfoDto(userId, authProvider);
} }
@Get('media') @Get('media')
@OpenApi(200) @OpenApi(200)
async getMyMedia(@RequestUser() user: User): Promise<MediaUploadDto[]> { async getMyMedia(@RequestUserInfo() user: User): Promise<MediaUploadDto[]> {
const media = await this.mediaService.listUploadsByUser(user); const media = await this.mediaService.getMediaUploadUuidsByUserId(user);
return await Promise.all( return await Promise.all(
media.map((media) => this.mediaService.toMediaUploadDto(media)), media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)),
); );
} }
@Delete() @Delete()
@OpenApi(204, 404, 500) @OpenApi(204, 404, 500)
async deleteUser(@RequestUser() user: User): Promise<void> { async deleteUser(@RequestUserInfo() userId: number): Promise<void> {
const mediaUploads = await this.mediaService.listUploadsByUser(user); const mediaUploads =
await this.mediaService.getMediaUploadUuidsByUserId(userId);
for (const mediaUpload of mediaUploads) { for (const mediaUpload of mediaUploads) {
await this.mediaService.deleteFile(mediaUpload); await this.mediaService.deleteFile(mediaUpload);
} }
this.logger.debug(`Deleted all media uploads of ${user.username}`); this.logger.debug(`Deleted all media uploads for user with id ${userId}`);
await this.userService.deleteUser(user); await this.userService.deleteUser(userId);
this.logger.debug(`Deleted ${user.username}`); this.logger.debug(`Deleted user with id ${userId}`);
} }
@Put('profile') @Put('profile')
@OpenApi(200) @OpenApi(200)
async updateProfile( async updateProfile(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@Body('displayName') newDisplayName: string, @Body('displayName') newDisplayName: string,
): Promise<void> { ): Promise<void> {
await this.userService.updateUser( await this.userService.updateUser(
user, userId,
newDisplayName, newDisplayName,
undefined, undefined,
undefined, undefined,

View file

@ -4,37 +4,23 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { MediaUploadDto, MediaUploadSchema } from '@hedgedoc/commons'; import { MediaUploadDto, MediaUploadSchema } from '@hedgedoc/commons';
import { import { BadRequestException, Controller, Delete, Get, Param, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common';
BadRequestException,
Controller,
Delete,
Get,
Param,
Post,
Res,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { RequestUserInfo } from 'src/api/utils/request-user-id.decorator';
import { SessionGuard } from '../../../auth/session.guard'; import { SessionGuard } from '../../../auth/session.guard';
import { User } from '../../../database/user.entity';
import { PermissionError } from '../../../errors/errors'; import { PermissionError } from '../../../errors/errors';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { MulterFile } from '../../../media/multer-file.interface'; 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 { PermissionsGuard } from '../../../permissions/permissions.guard';
import { PermissionsService } from '../../../permissions/permissions.service';
import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequirePermission } from '../../../permissions/require-permission.decorator';
import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RequiredPermission } from '../../../permissions/required-permission.enum';
import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor'; import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestNote } from '../../utils/request-note.decorator'; import { RequestNoteId } from '../../utils/request-note-id.decorator';
import { RequestUser } from '../../utils/request-user.decorator';
@UseGuards(SessionGuard) @UseGuards(SessionGuard)
@OpenApi(401) @OpenApi(401)
@ -44,7 +30,7 @@ export class MediaController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private mediaService: MediaService, private mediaService: MediaService,
private permissionsService: PermissionsService, private permissionsService: PermissionService,
) { ) {
this.logger.setContext(MediaController.name); this.logger.setContext(MediaController.name);
} }
@ -64,7 +50,7 @@ export class MediaController {
}) })
@ApiHeader({ @ApiHeader({
name: 'HedgeDoc-Note', name: 'HedgeDoc-Note',
description: 'ID or alias of the parent note', description: 'ID or aliases of the parent note',
}) })
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
@ -83,71 +69,60 @@ export class MediaController {
) )
async uploadMedia( async uploadMedia(
@UploadedFile() file: MulterFile | undefined, @UploadedFile() file: MulterFile | undefined,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserInfo({ guestsAllowed: true }) userId: number | null,
): Promise<MediaUploadDto> { ): Promise<string> {
if (file === undefined) { if (file === undefined) {
throw new BadRequestException('Request does not contain a file'); throw new BadRequestException('Request does not contain a file');
} }
if (user) { if (userId) {
this.logger.debug( this.logger.debug(
`Received filename '${file.originalname}' for note '${note.publicId}' from user '${user.username}'`, `Received filename '${file.originalname}' for note '${noteId}' from user '${userId}'`,
'uploadMedia', 'uploadMedia',
); );
} else { } else {
this.logger.debug( this.logger.debug(
`Received filename '${file.originalname}' for note '${note.publicId}' from not logged in user`, `Received filename '${file.originalname}' for note '${noteId}' from not logged in user`,
'uploadMedia', 'uploadMedia',
); );
} }
const upload = await this.mediaService.saveFile( const uploadUuid = await this.mediaService.saveFile(
file.originalname, file.originalname,
file.buffer, file.buffer,
user, userId,
note, noteId,
); );
return await this.mediaService.toMediaUploadDto(upload); return uploadUuid;
} }
@Get(':uuid') @Get(':uuid')
@OpenApi(200, 404, 500) @OpenApi(200, 404, 500)
async getMedia( async getMedia(@Param('uuid') uuid: string): Promise<MediaUploadDto> {
@Param('uuid') uuid: string, return (await this.mediaService.getMediaUploadDtosByUuids([uuid]))[0];
@Res() response: Response,
): Promise<void> {
const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
const dto = await this.mediaService.toMediaUploadDto(mediaUpload);
response.send(dto);
} }
@Delete(':uuid') @Delete(':uuid')
@OpenApi(204, 403, 404, 500) @OpenApi(204, 403, 404, 500)
async deleteMedia( async deleteMedia(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@Param('uuid') uuid: string, @Param('uuid') uuid: string,
): Promise<void> { ): Promise<void> {
const mediaUpload = await this.mediaService.findUploadByUuid(uuid); const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
if ( if (
await this.permissionsService.checkMediaDeletePermission( await this.permissionsService.checkMediaDeletePermission(userId, uuid)
user,
mediaUpload,
)
) { ) {
this.logger.debug( this.logger.debug(
`Deleting '${uuid}' for user '${user.username}'`, `Deleting '${uuid}' for user '${userId}'`,
'deleteMedia', 'deleteMedia',
); );
await this.mediaService.deleteFile(mediaUpload); await this.mediaService.deleteFile(mediaUpload);
} else { } else {
this.logger.warn( 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', 'deleteMedia',
); );
const mediaUploadNote = await mediaUpload.note;
throw new PermissionError( throw new PermissionError(
`Neither file '${uuid}' nor note '${ `'${userId}' does neither own the upload '${uuid}' nor the note associacted with this upload'`,
mediaUploadNote?.publicId ?? 'unknown'
}'is owned by '${user.username}'`,
); );
} }
} }

View file

@ -39,18 +39,18 @@ import { HistoryService } from '../../../history/history.service';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { Note } from '../../../notes/note.entity'; 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 { PermissionsGuard } from '../../../permissions/permissions.guard';
import { PermissionsService } from '../../../permissions/permissions.service';
import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequirePermission } from '../../../permissions/require-permission.decorator';
import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RequiredPermission } from '../../../permissions/required-permission.enum';
import { RevisionsService } from '../../../revisions/revisions.service'; import { RevisionsService } from '../../../revisions/revisions.service';
import { UsersService } from '../../../users/users.service'; import { UsersService } from '../../../users/users.service';
import { GetNoteInterceptor } from '../../utils/get-note.interceptor'; import { GetNoteIdInterceptor } from '../../utils/get-note-id.interceptor';
import { MarkdownBody } from '../../utils/markdown-body.decorator'; import { MarkdownBody } from '../../utils/markdown-body.decorator';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestNote } from '../../utils/request-note.decorator'; import { RequestNoteId } from '../../utils/request-note-id.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { RequestUserId } from '../../utils/request-user.decorator';
@UseGuards(SessionGuard, PermissionsGuard) @UseGuards(SessionGuard, PermissionsGuard)
@OpenApi(401, 403) @OpenApi(401, 403)
@ -59,12 +59,12 @@ import { RequestUser } from '../../utils/request-user.decorator';
export class NotesController { export class NotesController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private noteService: NotesService, private noteService: NoteService,
private historyService: HistoryService, private historyService: HistoryService,
private userService: UsersService, private userService: UsersService,
private mediaService: MediaService, private mediaService: MediaService,
private revisionsService: RevisionsService, private revisionsService: RevisionsService,
private permissionService: PermissionsService, private permissionService: PermissionService,
private groupService: GroupsService, private groupService: GroupsService,
) { ) {
this.logger.setContext(NotesController.name); this.logger.setContext(NotesController.name);
@ -73,10 +73,10 @@ export class NotesController {
@Get(':noteIdOrAlias') @Get(':noteIdOrAlias')
@OpenApi(200) @OpenApi(200)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async getNote( async getNote(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) user: User | null,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<NoteDto> { ): Promise<NoteDto> {
await this.historyService.updateHistoryEntryTimestamp(note, user); await this.historyService.updateHistoryEntryTimestamp(note, user);
return await this.noteService.toNoteDto(note); return await this.noteService.toNoteDto(note);
@ -85,11 +85,13 @@ export class NotesController {
@Get(':noteIdOrAlias/media') @Get(':noteIdOrAlias/media')
@OpenApi(200) @OpenApi(200)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async getNotesMedia(@RequestNote() note: Note): Promise<MediaUploadDto[]> { async getNotesMedia(
const media = await this.mediaService.listUploadsByNote(note); @RequestNoteId() noteId: number,
): Promise<MediaUploadDto[]> {
const media = await this.mediaService.getMediaUploadUuidsByNoteId(noteId);
return await Promise.all( return await Promise.all(
media.map((media) => this.mediaService.toMediaUploadDto(media)), media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)),
); );
} }
@ -97,7 +99,7 @@ export class NotesController {
@OpenApi(201, 413) @OpenApi(201, 413)
@RequirePermission(RequiredPermission.CREATE) @RequirePermission(RequiredPermission.CREATE)
async createNote( async createNote(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) user: User | null,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
this.logger.debug('Got raw markdown:\n' + text, 'createNote'); this.logger.debug('Got raw markdown:\n' + text, 'createNote');
@ -110,7 +112,7 @@ export class NotesController {
@OpenApi(201, 400, 404, 409, 413) @OpenApi(201, 400, 404, 409, 413)
@RequirePermission(RequiredPermission.CREATE) @RequirePermission(RequiredPermission.CREATE)
async createNamedNote( async createNamedNote(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) userId: User | null,
@Param('noteAlias') noteAlias: string, @Param('noteAlias') noteAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
@ -123,13 +125,14 @@ export class NotesController {
@Delete(':noteIdOrAlias') @Delete(':noteIdOrAlias')
@OpenApi(204, 404, 500) @OpenApi(204, 404, 500)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async deleteNote( async deleteNote(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Body() noteMediaDeletionDto: NoteMediaDeletionDto, @Body() noteMediaDeletionDto: NoteMediaDeletionDto,
): Promise<void> { ): Promise<void> {
const mediaUploads = await this.mediaService.listUploadsByNote(note); const mediaUploads =
await this.mediaService.getMediaUploadUuidsByNoteId(note);
for (const mediaUpload of mediaUploads) { for (const mediaUpload of mediaUploads) {
if (!noteMediaDeletionDto.keepMedia) { if (!noteMediaDeletionDto.keepMedia) {
await this.mediaService.deleteFile(mediaUpload); await this.mediaService.deleteFile(mediaUpload);
@ -143,12 +146,12 @@ export class NotesController {
return; return;
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/metadata') @Get(':noteIdOrAlias/metadata')
async getNoteMetadata( async getNoteMetadata(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) user: User | null,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<NoteMetadataDto> { ): Promise<NoteMetadataDto> {
return await this.noteService.toNoteMetadataDto(note); return await this.noteService.toNoteMetadataDto(note);
} }
@ -156,34 +159,29 @@ export class NotesController {
@Get(':noteIdOrAlias/revisions') @Get(':noteIdOrAlias/revisions')
@OpenApi(200, 404) @OpenApi(200, 404)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async getNoteRevisions( async getNoteRevisions(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) user: User | null,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<RevisionMetadataDto[]> { ): Promise<RevisionMetadataDto[]> {
const revisions = await this.revisionsService.getAllRevisions(note); return await this.revisionsService.getAllRevisionMetadataDto(note);
return await Promise.all(
revisions.map((revision) =>
this.revisionsService.toRevisionMetadataDto(revision),
),
);
} }
@Delete(':noteIdOrAlias/revisions') @Delete(':noteIdOrAlias/revisions')
@OpenApi(204, 404) @OpenApi(204, 404)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async purgeNoteRevisions( async purgeNoteRevisions(
@RequestUser() user: User, @RequestUserId() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
): Promise<void> { ): Promise<void> {
this.logger.debug( this.logger.debug(
`Purging history of note: ${note.id}`, `Purging history of note: ${noteId}`,
'purgeNoteRevisions', 'purgeNoteRevisions',
); );
await this.revisionsService.purgeRevisions(note); await this.revisionsService.purgeRevisions(noteId);
this.logger.debug( this.logger.debug(
`Successfully purged history of note ${note.id}`, `Successfully purged history of note ${noteId}`,
'purgeNoteRevisions', 'purgeNoteRevisions',
); );
return; return;
@ -192,49 +190,44 @@ export class NotesController {
@Get(':noteIdOrAlias/revisions/:revisionId') @Get(':noteIdOrAlias/revisions/:revisionId')
@OpenApi(200, 404) @OpenApi(200, 404)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
async getNoteRevision( async getNoteRevision(
@RequestUser({ guestsAllowed: true }) user: User | null, @RequestUserId({ guestsAllowed: true }) user: User | null,
@RequestNote() note: Note,
@Param('revisionId') revisionId: number, @Param('revisionId') revisionId: number,
): Promise<RevisionDto> { ): Promise<RevisionDto> {
return await this.revisionsService.toRevisionDto( return await this.revisionsService.getRevisionDto(revisionId);
await this.revisionsService.getRevision(note, revisionId),
);
} }
@Put(':noteIdOrAlias/metadata/permissions/users/:username') @Put(':noteIdOrAlias/metadata/permissions/users/:username')
@OpenApi(200, 403, 404) @OpenApi(200, 403, 404)
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
async setUserPermission( async setUserPermission(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Param('username') username: NoteUserPermissionUpdateDto['username'], @Param('username') username: NoteUserPermissionUpdateDto['username'],
@Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'], @Body('canEdit') canEdit: NoteUserPermissionUpdateDto['canEdit'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionUser = await this.userService.getUserByUsername(username);
const returnedNote = await this.permissionService.setUserPermission( const returnedNote = await this.permissionService.setUserPermission(
note, note,
permissionUser, makeUsernameLowercase(username),
canEdit, canEdit,
); );
return await this.noteService.toNotePermissionsDto(returnedNote); return await this.noteService.toNotePermissionsDto(returnedNote);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Delete(':noteIdOrAlias/metadata/permissions/users/:username') @Delete(':noteIdOrAlias/metadata/permissions/users/:username')
async removeUserPermission( async removeUserPermission(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Param('username') username: NoteUserPermissionEntryDto['username'], @Param('username') username: NoteUserPermissionEntryDto['username'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
try { try {
const permissionUser = await this.userService.getUserByUsername(username);
const returnedNote = await this.permissionService.removeUserPermission( const returnedNote = await this.permissionService.removeUserPermission(
note, note,
permissionUser, username,
); );
return await this.noteService.toNotePermissionsDto(returnedNote); return await this.noteService.toNotePermissionsDto(returnedNote);
} catch (e) { } catch (e) {
@ -247,54 +240,49 @@ export class NotesController {
} }
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Put(':noteIdOrAlias/metadata/permissions/groups/:groupName') @Put(':noteIdOrAlias/metadata/permissions/groups/:groupName')
async setGroupPermission( async setGroupPermission(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Param('groupName') groupName: NoteGroupPermissionUpdateDto['groupName'], @Param('groupName') groupName: NoteGroupPermissionUpdateDto['groupName'],
@Body('canEdit') canEdit: NoteGroupPermissionUpdateDto['canEdit'], @Body('canEdit') canEdit: NoteGroupPermissionUpdateDto['canEdit'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionGroup = await this.groupService.getGroupByName(groupName);
const returnedNote = await this.permissionService.setGroupPermission( const returnedNote = await this.permissionService.setGroupPermission(
note, note,
permissionGroup, groupName,
canEdit, canEdit,
); );
return await this.noteService.toNotePermissionsDto(returnedNote); return await this.noteService.toNotePermissionsDto(returnedNote);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@UseGuards(PermissionsGuard) @UseGuards(PermissionsGuard)
@Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName') @Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName')
async removeGroupPermission( async removeGroupPermission(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Param('groupName') groupName: NoteGroupPermissionEntryDto['groupName'], @Param('groupName') groupName: NoteGroupPermissionEntryDto['groupName'],
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionGroup = await this.groupService.getGroupByName(groupName);
const returnedNote = await this.permissionService.removeGroupPermission( const returnedNote = await this.permissionService.removeGroupPermission(
note, note,
permissionGroup, groupName,
); );
return await this.noteService.toNotePermissionsDto(returnedNote); return await this.noteService.toNotePermissionsDto(returnedNote);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Put(':noteIdOrAlias/metadata/permissions/owner') @Put(':noteIdOrAlias/metadata/permissions/owner')
async changeOwner( async changeOwner(
@RequestUser() user: User, @RequestUserId() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Body() changeNoteOwnerDto: ChangeNoteOwnerDto, @Body() changeNoteOwnerDto: ChangeNoteOwnerDto,
): Promise<NoteDto> { ): Promise<NoteDto> {
const owner = await this.userService.getUserByUsername(
changeNoteOwnerDto.owner,
);
return await this.noteService.toNoteDto( return await this.noteService.toNoteDto(
await this.permissionService.changeOwner(note, owner), await this.permissionService.changeOwner(note, newOwner),
); );
} }
} }

View file

@ -1,23 +1,24 @@
/* /*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AliasModule } from '../../alias/alias.module';
import { ApiTokenModule } from '../../api-token/api-token.module'; import { ApiTokenModule } from '../../api-token/api-token.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module'; import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
import { GroupsModule } from '../../groups/groups.module'; import { GroupsModule } from '../../groups/groups.module';
import { HistoryModule } from '../../history/history.module';
import { LoggerModule } from '../../logger/logger.module'; import { LoggerModule } from '../../logger/logger.module';
import { MediaModule } from '../../media/media.module'; import { MediaModule } from '../../media/media.module';
import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module'; import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module'; import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module'; import { UsersModule } from '../../users/users.module';
import { AliasController } from './alias/alias.controller'; import { AliasController } from './alias/alias.controller';
import { ApiTokensController } from './api-tokens/api-tokens.controller';
import { AuthController } from './auth/auth.controller'; import { AuthController } from './auth/auth.controller';
import { GuestController } from './auth/guest/guest.controller';
import { LdapController } from './auth/ldap/ldap.controller'; import { LdapController } from './auth/ldap/ldap.controller';
import { LocalController } from './auth/local/local.controller'; import { LocalController } from './auth/local/local.controller';
import { OidcController } from './auth/oidc/oidc.controller'; import { OidcController } from './auth/oidc/oidc.controller';
@ -27,7 +28,6 @@ import { HistoryController } from './me/history/history.controller';
import { MeController } from './me/me.controller'; import { MeController } from './me/me.controller';
import { MediaController } from './media/media.controller'; import { MediaController } from './media/media.controller';
import { NotesController } from './notes/notes.controller'; import { NotesController } from './notes/notes.controller';
import { ApiTokensController } from './tokens/api-tokens.controller';
import { UsersController } from './users/users.controller'; import { UsersController } from './users/users.controller';
@Module({ @Module({
@ -36,9 +36,8 @@ import { UsersController } from './users/users.controller';
UsersModule, UsersModule,
ApiTokenModule, ApiTokenModule,
FrontendConfigModule, FrontendConfigModule,
HistoryModule,
PermissionsModule, PermissionsModule,
NotesModule, AliasModule,
MediaModule, MediaModule,
RevisionsModule, RevisionsModule,
AuthModule, AuthModule,
@ -47,6 +46,7 @@ import { UsersController } from './users/users.controller';
controllers: [ controllers: [
ApiTokensController, ApiTokensController,
ConfigController, ConfigController,
GuestController,
MediaController, MediaController,
HistoryController, HistoryController,
MeController, MeController,

View file

@ -31,7 +31,7 @@ export class UsersController {
async checkUsername( async checkUsername(
@Body() usernameCheck: UsernameCheckDto, @Body() usernameCheck: UsernameCheckDto,
): Promise<UsernameCheckResponseDto> { ): Promise<UsernameCheckResponseDto> {
const userExists = await this.userService.checkIfUserExists( const userExists = await this.userService.isUsernameTaken(
usernameCheck.username, usernameCheck.username,
); );
// TODO Check if username is blocked // TODO Check if username is blocked

View file

@ -22,14 +22,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { AliasService } from '../../../alias/alias.service';
import { ApiTokenGuard } from '../../../api-token/api-token.guard'; import { ApiTokenGuard } from '../../../api-token/api-token.guard';
import { User } from '../../../database/user.entity'; import { User } from '../../../database/types';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { AliasService } from '../../../notes/alias.service'; import { NoteService } from '../../../notes/note.service';
import { NotesService } from '../../../notes/notes.service'; import { PermissionService } from '../../../permissions/permission.service';
import { PermissionsService } from '../../../permissions/permissions.service'; import { RequestUserId } from '../../utils/decorator/request-user.decorator';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestUser } from '../../utils/request-user.decorator';
@UseGuards(ApiTokenGuard) @UseGuards(ApiTokenGuard)
@OpenApi(401) @OpenApi(401)
@ -40,8 +40,8 @@ export class AliasController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private aliasService: AliasService, private aliasService: AliasService,
private noteService: NotesService, private noteService: NoteService,
private permissionsService: PermissionsService, private permissionsService: PermissionService,
) { ) {
this.logger.setContext(AliasController.name); this.logger.setContext(AliasController.name);
} }
@ -50,20 +50,20 @@ export class AliasController {
@OpenApi( @OpenApi(
{ {
code: 200, code: 200,
description: 'The new alias', description: 'The new aliases',
schema: AliasSchema, schema: AliasSchema,
}, },
403, 403,
404, 404,
) )
async addAlias( async addAlias(
@RequestUser() user: User, @RequestUserId() userId: number,
@Body() newAliasDto: AliasCreateDto, @Body() newAliasDto: AliasCreateDto,
): Promise<AliasDto> { ): Promise<AliasDto> {
const note = await this.noteService.getNoteByIdOrAlias( const note = await this.noteService.getNoteIdByAlias(
newAliasDto.noteIdOrAlias, newAliasDto.noteIdOrAlias,
); );
if (!(await this.permissionsService.isOwner(user, note))) { if (!(await this.permissionsService.isOwner(userId, note))) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }
const updatedAlias = await this.aliasService.addAlias( const updatedAlias = await this.aliasService.addAlias(
@ -73,18 +73,18 @@ export class AliasController {
return this.aliasService.toAliasDto(updatedAlias, note); return this.aliasService.toAliasDto(updatedAlias, note);
} }
@Put(':alias') @Put(':aliases')
@OpenApi( @OpenApi(
{ {
code: 200, code: 200,
description: 'The updated alias', description: 'The updated aliases',
schema: AliasSchema, schema: AliasSchema,
}, },
403, 403,
404, 404,
) )
async makeAliasPrimary( async makeAliasPrimary(
@RequestUser() user: User, @RequestUserId() user: User,
@Param('alias') alias: string, @Param('alias') alias: string,
@Body() changeAliasDto: AliasUpdateDto, @Body() changeAliasDto: AliasUpdateDto,
): Promise<AliasDto> { ): Promise<AliasDto> {
@ -93,7 +93,7 @@ export class AliasController {
`The field 'primaryAlias' must be set to 'true'.`, `The field 'primaryAlias' must be set to 'true'.`,
); );
} }
const note = await this.noteService.getNoteByIdOrAlias(alias); const note = await this.noteService.getNoteIdByAlias(alias);
if (!(await this.permissionsService.isOwner(user, note))) { if (!(await this.permissionsService.isOwner(user, note))) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }
@ -101,21 +101,21 @@ export class AliasController {
return this.aliasService.toAliasDto(updatedAlias, note); return this.aliasService.toAliasDto(updatedAlias, note);
} }
@Delete(':alias') @Delete(':aliases')
@OpenApi( @OpenApi(
{ {
code: 204, code: 204,
description: 'The alias was deleted', description: 'The aliases was deleted',
}, },
400, 400,
403, 403,
404, 404,
) )
async removeAlias( async removeAlias(
@RequestUser() user: User, @RequestUserId() user: User,
@Param('alias') alias: AliasDto['name'], @Param('alias') alias: AliasDto['name'],
): Promise<void> { ): Promise<void> {
const note = await this.noteService.getNoteByIdOrAlias(alias); const note = await this.noteService.getNoteIdByAlias(alias);
if (!(await this.permissionsService.isOwner(user, note))) { if (!(await this.permissionsService.isOwner(user, note))) {
throw new UnauthorizedException('Reading note denied!'); throw new UnauthorizedException('Reading note denied!');
} }

View file

@ -4,38 +4,26 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { import {
FullUserInfoDto,
FullUserInfoSchema, FullUserInfoSchema,
LoginUserInfoDto,
MediaUploadDto, MediaUploadDto,
MediaUploadSchema, MediaUploadSchema,
NoteMetadataDto, NoteMetadataDto,
NoteMetadataSchema, NoteMetadataSchema,
ProviderType,
} from '@hedgedoc/commons'; } from '@hedgedoc/commons';
import { import { Controller, Get, UseGuards } from '@nestjs/common';
Body,
Controller,
Delete,
Get,
Put,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { User } from 'src/database/types';
import { ApiTokenGuard } from '../../../api-token/api-token.guard'; 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 { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { Note } from '../../../notes/note.entity'; import { NoteService } from '../../../notes/note.service';
import { NotesService } from '../../../notes/notes.service';
import { UsersService } from '../../../users/users.service'; import { UsersService } from '../../../users/users.service';
import { GetNoteInterceptor } from '../../utils/get-note.interceptor';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestNote } from '../../utils/request-note.decorator'; import { RequestUserInfo } from '../../utils/request-user-id.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { SessionAuthProvider } from '../../utils/session-authprovider.decorator';
@UseGuards(ApiTokenGuard) @UseGuards(ApiTokenGuard)
@OpenApi(401) @OpenApi(401)
@ -46,8 +34,7 @@ export class MeController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private usersService: UsersService, private usersService: UsersService,
private historyService: HistoryService, private notesService: NoteService,
private notesService: NotesService,
private mediaService: MediaService, private mediaService: MediaService,
) { ) {
this.logger.setContext(MeController.name); this.logger.setContext(MeController.name);
@ -59,67 +46,12 @@ export class MeController {
description: 'The user information', description: 'The user information',
schema: FullUserInfoSchema, schema: FullUserInfoSchema,
}) })
getMe(@RequestUser() user: User): FullUserInfoDto { async getMe(
return this.usersService.toFullUserDto(user); @RequestUserInfo() userId: number,
} @SessionAuthProvider() authProvider: ProviderType,
): Promise<LoginUserInfoDto> {
@Get('history') const user: User = await this.usersService.getUserById(userId);
@OpenApi({ return this.usersService.toLoginUserInfoDto(user, authProvider);
code: 200,
description: 'The history entries of the user',
isArray: true,
})
async getUserHistory(@RequestUser() user: User): Promise<HistoryEntryDto[]> {
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<HistoryEntryDto> {
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<HistoryEntryDto> {
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<void> {
await this.historyService.deleteHistoryEntry(note, user);
} }
@Get('notes') @Get('notes')
@ -129,8 +61,10 @@ export class MeController {
isArray: true, isArray: true,
schema: NoteMetadataSchema, schema: NoteMetadataSchema,
}) })
async getMyNotes(@RequestUser() user: User): Promise<NoteMetadataDto[]> { async getMyNotes(
const notes = this.notesService.getUserNotes(user); @RequestUserInfo() userId: number,
): Promise<NoteMetadataDto[]> {
const notes = this.notesService.getUserNotes(userId);
return await Promise.all( return await Promise.all(
(await notes).map((note) => this.notesService.toNoteMetadataDto(note)), (await notes).map((note) => this.notesService.toNoteMetadataDto(note)),
); );
@ -143,10 +77,10 @@ export class MeController {
isArray: true, isArray: true,
schema: MediaUploadSchema, schema: MediaUploadSchema,
}) })
async getMyMedia(@RequestUser() user: User): Promise<MediaUploadDto[]> { async getMyMedia(@RequestUserInfo() userId: number): Promise<MediaUploadDto[]> {
const media = await this.mediaService.listUploadsByUser(user); const media = await this.mediaService.getMediaUploadUuidsByUserId(userId);
return await Promise.all( return await Promise.all(
media.map((media) => this.mediaService.toMediaUploadDto(media)), media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)),
); );
} }
} }

View file

@ -27,20 +27,25 @@ import {
import { Response } from 'express'; import { Response } from 'express';
import { ApiTokenGuard } from '../../../api-token/api-token.guard'; 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 { PermissionError } from '../../../errors/errors';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { MulterFile } from '../../../media/multer-file.interface'; 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 { PermissionsGuard } from '../../../permissions/permissions.guard';
import { PermissionsService } from '../../../permissions/permissions.service';
import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequirePermission } from '../../../permissions/require-permission.decorator';
import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RequiredPermission } from '../../../permissions/required-permission.enum';
import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor'; import { NoteHeaderInterceptor } from '../../utils/note-header.interceptor';
import { OpenApi } from '../../utils/openapi.decorator'; import { OpenApi } from '../../utils/openapi.decorator';
import { RequestNote } from '../../utils/request-note.decorator'; import { RequestNoteId } from '../../utils/request-note-id.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { RequestUserId } from '../../utils/request-user.decorator';
@UseGuards(ApiTokenGuard) @UseGuards(ApiTokenGuard)
@OpenApi(401) @OpenApi(401)
@ -51,7 +56,7 @@ export class MediaController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private mediaService: MediaService, private mediaService: MediaService,
private permissionsService: PermissionsService, private permissionsService: PermissionService,
) { ) {
this.logger.setContext(MediaController.name); this.logger.setContext(MediaController.name);
} }
@ -71,7 +76,7 @@ export class MediaController {
}) })
@ApiHeader({ @ApiHeader({
name: 'HedgeDoc-Note', name: 'HedgeDoc-Note',
description: 'ID or alias of the parent note', description: 'ID or aliases of the parent note',
}) })
@OpenApi( @OpenApi(
{ {
@ -89,41 +94,36 @@ export class MediaController {
@UseInterceptors(NoteHeaderInterceptor) @UseInterceptors(NoteHeaderInterceptor)
@RequirePermission(RequiredPermission.WRITE) @RequirePermission(RequiredPermission.WRITE)
async uploadMedia( async uploadMedia(
@RequestUser() user: User, @RequestUserId() user: User,
@UploadedFile() file: MulterFile, @UploadedFile() file: MulterFile,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<MediaUploadDto> { ): Promise<MediaUploadDto> {
if (file === undefined) { if (file === undefined) {
throw new BadRequestException('Request does not contain a file'); throw new BadRequestException('Request does not contain a file');
} }
this.logger.debug( 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', 'uploadMedia',
); );
const upload = await this.mediaService.saveFile( const uploadUuid = await this.mediaService.saveFile(
file.originalname, file.originalname,
file.buffer, file.buffer,
user, user[FieldNameUser.id],
note, note[FieldNameNote.id],
); );
return await this.mediaService.toMediaUploadDto(upload); return await this.mediaService.getMediaUploadDtosByUuids(uploadUuid);
} }
@Get(':uuid') @Get(':uuid')
@OpenApi(200, 404, 500) @OpenApi(200, 404, 500)
async getMedia( async getMedia(@Param('uuid') uuid: string): Promise<MediaUploadDto> {
@Param('uuid') uuid: string, return await this.mediaService.getMediaUploadDtosByUuids(uuid);
@Res() response: Response,
): Promise<void> {
const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
const dto = await this.mediaService.toMediaUploadDto(mediaUpload);
response.send(dto);
} }
@Delete(':uuid') @Delete(':uuid')
@OpenApi(204, 403, 404, 500) @OpenApi(204, 403, 404, 500)
async deleteMedia( async deleteMedia(
@RequestUser() user: User, @RequestUserId() user: User,
@Param('uuid') uuid: string, @Param('uuid') uuid: string,
): Promise<void> { ): Promise<void> {
const mediaUpload = await this.mediaService.findUploadByUuid(uuid); const mediaUpload = await this.mediaService.findUploadByUuid(uuid);
@ -143,10 +143,10 @@ export class MediaController {
`${user.username} tried to delete '${uuid}', but is not the owner of upload or connected note`, `${user.username} tried to delete '${uuid}', but is not the owner of upload or connected note`,
'deleteMedia', 'deleteMedia',
); );
const mediaUploadNote = await mediaUpload.note; const mediaUploadNote = mediaUpload[FieldNameMediaUpload.noteId];
throw new PermissionError( throw new PermissionError(
`Neither file '${uuid}' nor note '${ `Neither file '${uuid}' nor note '${
mediaUploadNote?.publicId ?? 'unknown' mediaUploadNote ?? 'unknown'
}'is owned by '${user.username}'`, }'is owned by '${user.username}'`,
); );
} }

View file

@ -12,7 +12,6 @@ import {
NoteMetadataSchema, NoteMetadataSchema,
NotePermissionsDto, NotePermissionsDto,
NotePermissionsSchema, NotePermissionsSchema,
NotePermissionsUpdateDto,
NoteSchema, NoteSchema,
RevisionDto, RevisionDto,
RevisionMetadataDto, RevisionMetadataDto,
@ -20,7 +19,6 @@ import {
RevisionSchema, RevisionSchema,
} from '@hedgedoc/commons'; } from '@hedgedoc/commons';
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@ -34,25 +32,21 @@ import {
import { ApiSecurity, ApiTags } from '@nestjs/swagger'; import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ApiTokenGuard } from '../../../api-token/api-token.guard'; 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 { GroupsService } from '../../../groups/groups.service';
import { HistoryService } from '../../../history/history.service';
import { ConsoleLoggerService } from '../../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service'; import { MediaService } from '../../../media/media.service';
import { Note } from '../../../notes/note.entity'; import { NoteService } from '../../../notes/note.service';
import { NotesService } from '../../../notes/notes.service'; import { PermissionService } from '../../../permissions/permission.service';
import { PermissionsGuard } from '../../../permissions/permissions.guard'; import { PermissionsGuard } from '../../../permissions/permissions.guard';
import { PermissionsService } from '../../../permissions/permissions.service';
import { RequirePermission } from '../../../permissions/require-permission.decorator'; import { RequirePermission } from '../../../permissions/require-permission.decorator';
import { RequiredPermission } from '../../../permissions/required-permission.enum'; import { RequiredPermission } from '../../../permissions/required-permission.enum';
import { RevisionsService } from '../../../revisions/revisions.service'; import { RevisionsService } from '../../../revisions/revisions.service';
import { UsersService } from '../../../users/users.service'; import { UsersService } from '../../../users/users.service';
import { GetNoteInterceptor } from '../../utils/get-note.interceptor'; import { MarkdownBody } from '../../utils/decorators/markdown-body.decorator';
import { MarkdownBody } from '../../utils/markdown-body.decorator'; import { OpenApi } from '../../utils/decorators/openapi.decorator';
import { OpenApi } from '../../utils/openapi.decorator'; import { RequestNoteId } from '../../utils/decorators/request-note-id.decorator';
import { RequestNote } from '../../utils/request-note.decorator'; import { RequestUserId } from '../../utils/decorators/request-user-id.decorator';
import { RequestUser } from '../../utils/request-user.decorator'; import { GetNoteIdInterceptor } from '../../utils/interceptors/get-note-id.interceptor';
@UseGuards(ApiTokenGuard, PermissionsGuard) @UseGuards(ApiTokenGuard, PermissionsGuard)
@OpenApi(401) @OpenApi(401)
@ -62,13 +56,12 @@ import { RequestUser } from '../../utils/request-user.decorator';
export class NotesController { export class NotesController {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private noteService: NotesService, private noteService: NoteService,
private userService: UsersService, private userService: UsersService,
private groupService: GroupsService, private groupService: GroupsService,
private revisionsService: RevisionsService, private revisionsService: RevisionsService,
private historyService: HistoryService,
private mediaService: MediaService, private mediaService: MediaService,
private permissionService: PermissionsService, private permissionService: PermissionService,
) { ) {
this.logger.setContext(NotesController.name); this.logger.setContext(NotesController.name);
} }
@ -77,16 +70,15 @@ export class NotesController {
@Post() @Post()
@OpenApi(201, 403, 409, 413) @OpenApi(201, 403, 409, 413)
async createNote( async createNote(
@RequestUser() user: User, @RequestUserId() userId: number,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
this.logger.debug('Got raw markdown:\n' + text); this.logger.debug('Got raw markdown:\n' + text);
return await this.noteService.toNoteDto( const newNote = await this.noteService.createNote(text, userId);
await this.noteService.createNote(text, user), return await this.noteService.toNoteDto(newNote);
);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias') @Get(':noteIdOrAlias')
@OpenApi( @OpenApi(
@ -99,11 +91,10 @@ export class NotesController {
404, 404,
) )
async getNote( async getNote(
@RequestUser() user: User, @RequestUserId() _userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
): Promise<NoteDto> { ): Promise<NoteDto> {
await this.historyService.updateHistoryEntryTimestamp(note, user); return await this.noteService.toNoteDto(noteId);
return await this.noteService.toNoteDto(note);
} }
@RequirePermission(RequiredPermission.CREATE) @RequirePermission(RequiredPermission.CREATE)
@ -120,26 +111,26 @@ export class NotesController {
413, 413,
) )
async createNamedNote( async createNamedNote(
@RequestUser() user: User, @RequestUserId() userId: number,
@Param('noteAlias') noteAlias: string, @Param('noteAlias') noteAlias: string,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote'); this.logger.debug('Got raw markdown:\n' + text, 'createNamedNote');
return await this.noteService.toNoteDto( const noteId = await this.noteService.createNote(text, userId, noteAlias);
await this.noteService.createNote(text, user, noteAlias), return await this.noteService.toNoteDto();
);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Delete(':noteIdOrAlias') @Delete(':noteIdOrAlias')
@OpenApi(204, 403, 404, 500) @OpenApi(204, 403, 404, 500)
async deleteNote( async deleteNote(
@RequestUser() user: User, @RequestUserInfo() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@Body() noteMediaDeletionDto: NoteMediaDeletionDto, @Body() noteMediaDeletionDto: NoteMediaDeletionDto,
): Promise<void> { ): Promise<void> {
const mediaUploads = await this.mediaService.listUploadsByNote(note); const mediaUploads =
await this.mediaService.getMediaUploadUuidsByNoteId(note);
for (const mediaUpload of mediaUploads) { for (const mediaUpload of mediaUploads) {
if (!noteMediaDeletionDto.keepMedia) { if (!noteMediaDeletionDto.keepMedia) {
await this.mediaService.deleteFile(mediaUpload); await this.mediaService.deleteFile(mediaUpload);
@ -153,7 +144,7 @@ export class NotesController {
return; return;
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.WRITE) @RequirePermission(RequiredPermission.WRITE)
@Put(':noteIdOrAlias') @Put(':noteIdOrAlias')
@OpenApi( @OpenApi(
@ -166,8 +157,8 @@ export class NotesController {
404, 404,
) )
async updateNote( async updateNote(
@RequestUser() user: User, @RequestUserInfo() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
@MarkdownBody() text: string, @MarkdownBody() text: string,
): Promise<NoteDto> { ): Promise<NoteDto> {
this.logger.debug('Got raw markdown:\n' + text, 'updateNote'); this.logger.debug('Got raw markdown:\n' + text, 'updateNote');
@ -176,7 +167,7 @@ export class NotesController {
); );
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/content') @Get(':noteIdOrAlias/content')
@OpenApi( @OpenApi(
@ -189,13 +180,13 @@ export class NotesController {
404, 404,
) )
async getNoteContent( async getNoteContent(
@RequestUser() user: User, @RequestUserInfo() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<string> { ): Promise<string> {
return await this.noteService.getNoteContent(note); return await this.noteService.getNoteContent(note);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/metadata') @Get(':noteIdOrAlias/metadata')
@OpenApi( @OpenApi(
@ -208,35 +199,13 @@ export class NotesController {
404, 404,
) )
async getNoteMetadata( async getNoteMetadata(
@RequestUser() user: User, @RequestUserInfo() user: User,
@RequestNote() note: Note, @RequestNoteId() note: Note,
): Promise<NoteMetadataDto> { ): Promise<NoteMetadataDto> {
return await this.noteService.toNoteMetadataDto(note); return await this.noteService.toNoteMetadataDto(note);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@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<NotePermissionsDto> {
return await this.noteService.toNotePermissionsDto(
await this.permissionService.updateNotePermissions(note, updateDto),
);
}
@UseInterceptors(GetNoteInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/metadata/permissions') @Get(':noteIdOrAlias/metadata/permissions')
@OpenApi( @OpenApi(
@ -249,13 +218,13 @@ export class NotesController {
404, 404,
) )
async getPermissions( async getPermissions(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
return await this.noteService.toNotePermissionsDto(note); return await this.permissionService.getPermissionsForNote(noteId);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Put(':noteIdOrAlias/metadata/permissions/users/:userName') @Put(':noteIdOrAlias/metadata/permissions/users/:userName')
@OpenApi( @OpenApi(
@ -268,21 +237,21 @@ export class NotesController {
404, 404,
) )
async setUserPermission( async setUserPermission(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@Param('userName') username: string, @Param('userName') username: string,
@Body('canEdit') canEdit: boolean, @Body('canEdit') canEdit: boolean,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionUser = await this.userService.getUserByUsername(username); const targetUserId = await this.userService.getUserIdByUsername(username);
const returnedNote = await this.permissionService.setUserPermission( await this.permissionService.setUserPermission(
note, noteId,
permissionUser, targetUserId,
canEdit, canEdit,
); );
return await this.noteService.toNotePermissionsDto(returnedNote); return await this.permissionService.getPermissionsForNote(noteId);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Delete(':noteIdOrAlias/metadata/permissions/users/:userName') @Delete(':noteIdOrAlias/metadata/permissions/users/:userName')
@OpenApi( @OpenApi(
@ -295,28 +264,16 @@ export class NotesController {
404, 404,
) )
async removeUserPermission( async removeUserPermission(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@Param('userName') username: string, @Param('userName') username: string,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
try { const targetUserId = await this.userService.getUserIdByUsername(username);
const permissionUser = await this.userService.getUserByUsername(username); await this.permissionService.removeUserPermission(noteId, targetUserId);
const returnedNote = await this.permissionService.removeUserPermission( return await this.permissionService.getPermissionsForNote(noteId);
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;
}
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Put(':noteIdOrAlias/metadata/permissions/groups/:groupName') @Put(':noteIdOrAlias/metadata/permissions/groups/:groupName')
@OpenApi( @OpenApi(
@ -329,21 +286,17 @@ export class NotesController {
404, 404,
) )
async setGroupPermission( async setGroupPermission(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@Param('groupName') groupName: string, @Param('groupName') groupName: string,
@Body('canEdit') canEdit: boolean, @Body('canEdit') canEdit: boolean,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionGroup = await this.groupService.getGroupByName(groupName); const groupId = await this.groupService.getGroupIdByName(groupName);
const returnedNote = await this.permissionService.setGroupPermission( await this.permissionService.setGroupPermission(noteId, groupId, canEdit);
note, return await this.permissionService.getPermissionsForNote(noteId);
permissionGroup,
canEdit,
);
return await this.noteService.toNotePermissionsDto(returnedNote);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName') @Delete(':noteIdOrAlias/metadata/permissions/groups/:groupName')
@OpenApi( @OpenApi(
@ -356,19 +309,16 @@ export class NotesController {
404, 404,
) )
async removeGroupPermission( async removeGroupPermission(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@Param('groupName') groupName: string, @Param('groupName') groupName: string,
): Promise<NotePermissionsDto> { ): Promise<NotePermissionsDto> {
const permissionGroup = await this.groupService.getGroupByName(groupName); const groupId = await this.groupService.getGroupIdByName(groupName);
const returnedNote = await this.permissionService.removeGroupPermission( await this.permissionService.removeGroupPermission(noteId, groupId);
note, return await this.permissionService.getPermissionsForNote(noteId);
permissionGroup,
);
return await this.noteService.toNotePermissionsDto(returnedNote);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.OWNER) @RequirePermission(RequiredPermission.OWNER)
@Put(':noteIdOrAlias/metadata/permissions/owner') @Put(':noteIdOrAlias/metadata/permissions/owner')
@OpenApi( @OpenApi(
@ -381,17 +331,19 @@ export class NotesController {
404, 404,
) )
async changeOwner( async changeOwner(
@RequestUser() user: User, @RequestUserInfo() userId: number,
@RequestNote() note: Note, @RequestNoteId() noteId: number,
@Body('newOwner') newOwner: string, @Body('newOwner') newOwner: string,
): Promise<NoteDto> { ): Promise<NoteMetadataDto> {
const owner = await this.userService.getUserByUsername(newOwner); const ownerUserId = await this.userService.getUserIdByUsername(newOwner);
return await this.noteService.toNoteDto( await this.permissionService.changeOwner(noteId, ownerUserId);
await this.permissionService.changeOwner(note, owner),
return await this.noteService.toNoteMetadataDto(
await this.noteService.getNoteById(),
); );
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/revisions') @Get(':noteIdOrAlias/revisions')
@OpenApi( @OpenApi(
@ -405,40 +357,32 @@ export class NotesController {
404, 404,
) )
async getNoteRevisions( async getNoteRevisions(
@RequestUser() user: User, @RequestNoteId() noteId: number,
@RequestNote() note: Note,
): Promise<RevisionMetadataDto[]> { ): Promise<RevisionMetadataDto[]> {
const revisions = await this.revisionsService.getAllRevisions(note); return await this.revisionsService.getAllRevisionMetadataDto(noteId);
return await Promise.all(
revisions.map((revision) =>
this.revisionsService.toRevisionMetadataDto(revision),
),
);
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/revisions/:revisionId') @Get(':noteIdOrAlias/revisions/:revisionUuid')
@OpenApi( @OpenApi(
{ {
code: 200, 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, schema: RevisionSchema,
}, },
403, 403,
404, 404,
) )
async getNoteRevision( async getNoteRevision(
@RequestUser() user: User, @Param('revisionUuid') revisionUuid: string,
@RequestNote() note: Note,
@Param('revisionId') revisionId: number,
): Promise<RevisionDto> { ): Promise<RevisionDto> {
return await this.revisionsService.toRevisionDto( return await this.revisionsService.toRevisionDto(
await this.revisionsService.getRevision(note, revisionId), await this.revisionsService.getRevision(revisionUuid),
); );
} }
@UseInterceptors(GetNoteInterceptor) @UseInterceptors(GetNoteIdInterceptor)
@RequirePermission(RequiredPermission.READ) @RequirePermission(RequiredPermission.READ)
@Get(':noteIdOrAlias/media') @Get(':noteIdOrAlias/media')
@OpenApi({ @OpenApi({
@ -448,12 +392,11 @@ export class NotesController {
schema: MediaUploadSchema, schema: MediaUploadSchema,
}) })
async getNotesMedia( async getNotesMedia(
@RequestUser() user: User, @RequestNoteId() noteId: number,
@RequestNote() note: Note,
): Promise<MediaUploadDto[]> { ): Promise<MediaUploadDto[]> {
const media = await this.mediaService.listUploadsByNote(note); const media = await this.mediaService.getMediaUploadUuidsByNoteId(noteId);
return await Promise.all( return await Promise.all(
media.map((media) => this.mediaService.toMediaUploadDto(media)), media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)),
); );
} }
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AliasModule } from '../../alias/alias.module';
import { ApiTokenModule } from '../../api-token/api-token.module'; import { ApiTokenModule } from '../../api-token/api-token.module';
import { GroupsModule } from '../../groups/groups.module'; import { GroupsModule } from '../../groups/groups.module';
import { HistoryModule } from '../../history/history.module';
import { LoggerModule } from '../../logger/logger.module'; import { LoggerModule } from '../../logger/logger.module';
import { MediaModule } from '../../media/media.module'; import { MediaModule } from '../../media/media.module';
import { MonitoringModule } from '../../monitoring/monitoring.module'; import { MonitoringModule } from '../../monitoring/monitoring.module';
import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module'; import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module'; import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module'; import { UsersModule } from '../../users/users.module';
@ -26,8 +25,7 @@ import { NotesController } from './notes/notes.controller';
ApiTokenModule, ApiTokenModule,
GroupsModule, GroupsModule,
UsersModule, UsersModule,
HistoryModule, AliasModule,
NotesModule,
RevisionsModule, RevisionsModule,
MonitoringModule, MonitoringModule,
LoggerModule, LoggerModule,

View file

@ -31,7 +31,7 @@ import {
okDescription, okDescription,
payloadTooLargeDescription, payloadTooLargeDescription,
unauthorizedDescription, unauthorizedDescription,
} from './descriptions'; } from '../descriptions';
export type HttpStatusCodes = export type HttpStatusCodes =
| 200 | 200

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,7 +9,7 @@ import {
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from '../request.type';
/** /**
* Extracts the {@link Note} object from a request * 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 * Will throw an {@link InternalServerErrorException} if no note is present
*/ */
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
export const RequestNote = createParamDecorator( export const RequestNoteId = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => { (data: unknown, ctx: ExecutionContext) => {
const request: CompleteRequest = ctx.switchToHttp().getRequest(); const request: CompleteRequest = ctx.switchToHttp().getRequest();
if (!request.note) { if (!request.noteId) {
// We should have a note here, otherwise something is wrong // We should have a note here, otherwise something is wrong
throw new InternalServerErrorException( throw new InternalServerErrorException('Request is missing a noteId');
'Request is missing a note object',
);
} }
return request.note; return request.noteId;
}, },
); );

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { AuthProviderType } from '@hedgedoc/commons';
import { import {
createParamDecorator, createParamDecorator,
ExecutionContext, ExecutionContext,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from '../request.type';
type RequestUserParameter = { type RequestUserIdParameter = {
guestsAllowed: boolean; 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 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 allowed, returns `null`.
* If no user is present and guests are not allowed, throws {@link UnauthorizedException}. * If no user is present and guests are not allowed, throws {@link UnauthorizedException}.
*/ */
// eslint-disable-next-line @typescript-eslint/naming-convention // 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, ctx: ExecutionContext,
) => { ) => {
const request: CompleteRequest = ctx.switchToHttp().getRequest(); const request: CompleteRequest = ctx.switchToHttp().getRequest();
if (!request.user) { if (
if (data.guestsAllowed) { !request.authProviderType ||
return null; (request.authProviderType === AuthProviderType.GUEST &&
} !data.guestsAllowed)
) {
throw new UnauthorizedException("You're not logged in"); throw new UnauthorizedException("You're not logged in");
} }
return request.user; return request.userId;
}, },
); );

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,7 +9,7 @@ import {
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from '../request.type';
/** /**
* Extracts the auth provider identifier from a session inside a request * Extracts the auth provider identifier from a session inside a request

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity'; import { Note } from '../../database/types';
import { NotesService } from '../../notes/notes.service'; import { NoteService } from '../../notes/note.service';
import { extractNoteFromRequest } from './extract-note-from-request'; import { extractNoteIdFromRequest } from './extract-note-id-from-request';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from './request.type';
describe('extract note from request', () => { describe('extract note from request', () => {
@ -17,11 +17,11 @@ describe('extract note from request', () => {
const mockNote1 = Mock.of<Note>({ id: 1 }); const mockNote1 = Mock.of<Note>({ id: 1 });
const mockNote2 = Mock.of<Note>({ id: 2 }); const mockNote2 = Mock.of<Note>({ id: 2 });
let notesService: NotesService; let notesService: NoteService;
beforeEach(() => { beforeEach(() => {
notesService = Mock.of<NotesService>({ notesService = Mock.of<NoteService>({
getNoteByIdOrAlias: async (id) => { getNoteIdByAlias: async (id) => {
if (id === mockNoteIdOrAlias1) { if (id === mockNoteIdOrAlias1) {
return mockNote1; return mockNote1;
} else if (id === mockNoteIdOrAlias2) { } else if (id === mockNoteIdOrAlias2) {
@ -54,17 +54,23 @@ describe('extract note from request', () => {
it('will return undefined if no id is present', async () => { it('will return undefined if no id is present', async () => {
const request = createRequest(undefined, undefined); 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 () => { it('can extract an id from parameters', async () => {
const request = createRequest(mockNoteIdOrAlias1, undefined); 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 () => { it('can extract an id from headers if no parameter is given', async () => {
const request = createRequest(undefined, mockNoteIdOrAlias1); 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 () => { it('can extract the first id from multiple id headers', async () => {
@ -72,16 +78,22 @@ describe('extract note from request', () => {
mockNoteIdOrAlias1, mockNoteIdOrAlias1,
mockNoteIdOrAlias2, 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 () => { it('will return undefined if no parameter and empty id header array', async () => {
const request = createRequest(undefined, []); 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 () => { it('will prefer the parameter over the header', async () => {
const request = createRequest(mockNoteIdOrAlias1, mockNoteIdOrAlias2); const request = createRequest(mockNoteIdOrAlias1, mockNoteIdOrAlias2);
expect(await extractNoteFromRequest(request, notesService)).toBe(mockNote1); expect(await extractNoteIdFromRequest(request, notesService)).toBe(
mockNote1,
);
}); });
}); });

View file

@ -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<Note | undefined> {
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;
}
}

View file

@ -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<Note[FieldNameNote.id] | undefined> {
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;
}

View file

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

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { CanActivate, Inject, Injectable } from '@nestjs/common'; import { CanActivate, Inject, Injectable } from '@nestjs/common';
import authConfiguration, { AuthConfig } from '../../config/auth.config'; import authConfiguration, { AuthConfig } from '../../../config/auth.config';
import { FeatureDisabledError } from '../../errors/errors'; import { FeatureDisabledError } from '../../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
@Injectable() @Injectable()
export class LoginEnabledGuard implements CanActivate { export class LoginEnabledGuard implements CanActivate {
@ -21,8 +21,11 @@ export class LoginEnabledGuard implements CanActivate {
canActivate(): boolean { canActivate(): boolean {
if (!this.authConfig.local.enableLogin) { if (!this.authConfig.local.enableLogin) {
this.logger.debug('Local auth is disabled.', 'canActivate'); throw new FeatureDisabledError(
throw new FeatureDisabledError('Local auth is disabled.'); 'Local auth is disabled.',
this.logger.getContext(),
'canActivate',
);
} }
return true; return true;
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { CanActivate, Inject, Injectable } from '@nestjs/common'; import { CanActivate, Inject, Injectable } from '@nestjs/common';
import authConfiguration, { AuthConfig } from '../../config/auth.config'; import authConfiguration, { AuthConfig } from '../../../config/auth.config';
import { FeatureDisabledError } from '../../errors/errors'; import { FeatureDisabledError } from '../../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../../logger/console-logger.service';
@Injectable() @Injectable()
export class RegistrationEnabledGuard implements CanActivate { export class RegistrationEnabledGuard implements CanActivate {
@ -21,8 +21,11 @@ export class RegistrationEnabledGuard implements CanActivate {
canActivate(): boolean { canActivate(): boolean {
if (!this.authConfig.local.enableRegister) { if (!this.authConfig.local.enableRegister) {
this.logger.debug('User registration is disabled.', 'canActivate'); throw new FeatureDisabledError(
throw new FeatureDisabledError('User registration is disabled'); 'User registration is disabled',
this.logger.getContext(),
'canActivate',
);
} }
return true; return true;
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,9 +8,9 @@ import { HttpArgumentsHost } from '@nestjs/common/interfaces/features/arguments-
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { Note } from '../../notes/note.entity'; import { Note } from '../../database/types';
import { NotesService } from '../../notes/notes.service'; import { NoteService } from '../../notes/note.service';
import { GetNoteInterceptor } from './get-note.interceptor'; import { GetNoteIdInterceptor } from './get-note-id.interceptor';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from './request.type';
describe('get note interceptor', () => { describe('get note interceptor', () => {
@ -21,15 +21,15 @@ describe('get note interceptor', () => {
handle: () => mockObservable, handle: () => mockObservable,
}); });
let notesService: NotesService; let notesService: NoteService;
let noteFetchSpy: jest.SpyInstance; let noteFetchSpy: jest.SpyInstance;
beforeEach(() => { beforeEach(() => {
notesService = Mock.of<NotesService>({ notesService = Mock.of<NoteService>({
getNoteByIdOrAlias: (id) => getNoteIdByAlias: (id) =>
id === mockNoteId ? Promise.resolve(mockNote) : Promise.reject(), id === mockNoteId ? Promise.resolve(mockNote) : Promise.reject(),
}); });
noteFetchSpy = jest.spyOn(notesService, 'getNoteByIdOrAlias'); noteFetchSpy = jest.spyOn(notesService, 'getNoteIdByAlias');
}); });
function mockExecutionContext(request: CompleteRequest) { function mockExecutionContext(request: CompleteRequest) {
@ -47,11 +47,11 @@ describe('get note interceptor', () => {
headers: { ['hedgedoc-note']: mockNoteId }, headers: { ['hedgedoc-note']: mockNoteId },
}); });
const context = mockExecutionContext(request); const context = mockExecutionContext(request);
const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService);
const result = await sut.intercept(context, nextCallHandler); const result = await sut.intercept(context, nextCallHandler);
expect(result).toBe(mockObservable); expect(result).toBe(mockObservable);
expect(request.note).toBe(mockNote); expect(request.noteId).toBe(mockNote);
expect(noteFetchSpy).toHaveBeenCalledTimes(1); expect(noteFetchSpy).toHaveBeenCalledTimes(1);
}); });
@ -60,11 +60,11 @@ describe('get note interceptor', () => {
params: { noteIdOrAlias: mockNoteId }, params: { noteIdOrAlias: mockNoteId },
}); });
const context = mockExecutionContext(request); const context = mockExecutionContext(request);
const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService);
const result = await sut.intercept(context, nextCallHandler); const result = await sut.intercept(context, nextCallHandler);
expect(result).toBe(mockObservable); expect(result).toBe(mockObservable);
expect(request.note).toBe(mockNote); expect(request.noteId).toBe(mockNote);
expect(noteFetchSpy).toHaveBeenCalledTimes(1); expect(noteFetchSpy).toHaveBeenCalledTimes(1);
}); });
@ -75,11 +75,11 @@ describe('get note interceptor', () => {
}); });
const context = mockExecutionContext(request); const context = mockExecutionContext(request);
const sut: GetNoteInterceptor = new GetNoteInterceptor(notesService); const sut: GetNoteIdInterceptor = new GetNoteIdInterceptor(notesService);
const result = await sut.intercept(context, nextCallHandler); const result = await sut.intercept(context, nextCallHandler);
expect(result).toBe(mockObservable); expect(result).toBe(mockObservable);
expect(request.note).toBe(undefined); expect(request.noteId).toBe(undefined);
expect(noteFetchSpy).toHaveBeenCalledTimes(0); expect(noteFetchSpy).toHaveBeenCalledTimes(0);
}); });
}); });

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -11,26 +11,26 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { NotesService } from '../../notes/notes.service'; import { NoteService } from '../../../notes/note.service';
import { extractNoteFromRequest } from './extract-note-from-request'; import { extractNoteIdFromRequest } from '../extract-note-id-from-request';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from '../request.type';
/** /**
* Saves the note identified by the `noteIdOrAlias` URL parameter * Saves the note identified by the `noteIdOrAlias` URL parameter
* under the `note` property of the request object. * under the `note` property of the request object.
*/ */
@Injectable() @Injectable()
export class GetNoteInterceptor implements NestInterceptor { export class GetNoteIdInterceptor implements NestInterceptor {
constructor(private noteService: NotesService) {} constructor(private noteService: NoteService) {}
async intercept<T>( async intercept<T>(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler, next: CallHandler,
): Promise<Observable<T>> { ): Promise<Observable<T>> {
const request: CompleteRequest = context.switchToHttp().getRequest(); const request: CompleteRequest = context.switchToHttp().getRequest();
const note = await extractNoteFromRequest(request, this.noteService); const noteId = await extractNoteIdFromRequest(request, this.noteService);
if (note !== undefined) { if (noteId !== undefined) {
request.note = note; request.noteId = noteId;
} }
return next.handle(); return next.handle();
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -11,8 +11,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { NotesService } from '../../notes/notes.service'; import { NoteService } from '../../../notes/note.service';
import { CompleteRequest } from './request.type'; import { CompleteRequest } from '../request.type';
/** /**
* Saves the note identified by the `HedgeDoc-Note` header * Saves the note identified by the `HedgeDoc-Note` header
@ -20,7 +20,7 @@ import { CompleteRequest } from './request.type';
*/ */
@Injectable() @Injectable()
export class NoteHeaderInterceptor implements NestInterceptor { export class NoteHeaderInterceptor implements NestInterceptor {
constructor(private noteService: NotesService) {} constructor(private noteService: NoteService) {}
async intercept<T>( async intercept<T>(
context: ExecutionContext, context: ExecutionContext,
@ -28,7 +28,7 @@ export class NoteHeaderInterceptor implements NestInterceptor {
): Promise<Observable<T>> { ): Promise<Observable<T>> {
const request: CompleteRequest = context.switchToHttp().getRequest(); const request: CompleteRequest = context.switchToHttp().getRequest();
const noteId: string = request.headers['hedgedoc-note'] as string; 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(); return next.handle();
} }
} }

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { AuthProviderType } from '@hedgedoc/commons';
import { Request } from 'express'; import { Request } from 'express';
import { SessionState } from 'src/sessions/session-state.type';
import { User } from '../../database/user.entity'; import { FieldNameNote, FieldNameUser, Note, User } from '../../database/types';
import { Note } from '../../notes/note.entity';
import { SessionState } from '../../sessions/session.service';
export type CompleteRequest = Request & { export type CompleteRequest = Request & {
user?: User; userId?: User[FieldNameUser.id];
note?: Note; authProviderType?: AuthProviderType;
noteId?: Note[FieldNameNote.id];
session?: SessionState; session?: SessionState;
}; };
export type RequestWithSession = Request & {
session: SessionState;
};

View file

@ -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 * 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 { ConsoleLoggerService } from './logger/console-logger.service';
import { BackendType } from './media/backends/backend-type.enum'; import { BackendType } from './media/backends/backend-type.enum';
import { SessionService } from './sessions/session.service'; import { SessionService } from './sessions/session.service';
import { setupSpecialGroups } from './utils/createSpecialGroups';
import { setupSessionMiddleware } from './utils/session'; import { setupSessionMiddleware } from './utils/session';
import { setupValidationPipe } from './utils/setup-pipes'; import { setupValidationPipe } from './utils/setup-pipes';
import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger'; import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
@ -29,12 +28,12 @@ export async function setupApp(
mediaConfig: MediaConfig, mediaConfig: MediaConfig,
logger: ConsoleLoggerService, logger: ConsoleLoggerService,
): Promise<void> { ): Promise<void> {
// Setup OpenAPI documentation
await setupPublicApiDocs(app); await setupPublicApiDocs(app);
logger.log( logger.log(
`Serving OpenAPI docs for public API under '/api/doc/v2'`, `Serving OpenAPI docs for public API under '/api/doc/v2'`,
'AppBootstrap', 'AppBootstrap',
); );
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
await setupPrivateApiDocs(app); await setupPrivateApiDocs(app);
logger.log( logger.log(
@ -43,14 +42,14 @@ export async function setupApp(
); );
} }
await setupSpecialGroups(app); // Setup session handling
setupSessionMiddleware( setupSessionMiddleware(
app, app,
authConfig, authConfig,
app.get(SessionService).getTypeormStore(), app.get(SessionService).getSessionStore(),
); );
// Enable web security aspects
app.enableCors({ app.enableCors({
origin: appConfig.rendererBaseUrl, origin: appConfig.rendererBaseUrl,
}); });
@ -58,9 +57,14 @@ export async function setupApp(
`Enabling CORS for '${appConfig.rendererBaseUrl}'`, `Enabling CORS for '${appConfig.rendererBaseUrl}'`,
'AppBootstrap', '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)); app.useGlobalPipes(setupValidationPipe(logger));
// Map URL paths to directories
if (mediaConfig.backend.use === BackendType.FILESYSTEM) { if (mediaConfig.backend.use === BackendType.FILESYSTEM) {
logger.log( logger.log(
`Serving the local folder '${mediaConfig.backend.filesystem.uploadPath}' under '/uploads'`, `Serving the local folder '${mediaConfig.backend.filesystem.uploadPath}' under '/uploads'`,
@ -70,7 +74,6 @@ export async function setupApp(
prefix: '/uploads/', prefix: '/uploads/',
}); });
} }
logger.log( logger.log(
`Serving the local folder 'public' under '/public'`, `Serving the local folder 'public' under '/public'`,
'AppBootstrap', 'AppBootstrap',
@ -78,9 +81,14 @@ export async function setupApp(
app.useStaticAssets('public', { app.useStaticAssets('public', {
prefix: '/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); const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter)); app.useGlobalFilters(new ErrorExceptionMapping(logger, httpAdapter));
app.useWebSocketAdapter(new WsAdapter(app)); app.useWebSocketAdapter(new WsAdapter(app));
// Enable hooks on app shutdown, like saving notes into the database
app.enableShutdownHooks(); app.enableShutdownHooks();
} }

View file

@ -8,20 +8,20 @@ import { ConfigModule } from '@nestjs/config';
import { RouterModule, Routes } from '@nestjs/core'; import { RouterModule, Routes } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; 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 { ApiTokenModule } from './api-token/api-token.module';
import { PrivateApiModule } from './api/private/private-api.module'; import { PrivateApiModule } from './api/private/private-api.module';
import { PublicApiModule } from './api/public/public-api.module'; import { PublicApiModule } from './api/public/public-api.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AuthorsModule } from './authors/authors.module';
import appConfig from './config/app.config'; import appConfig from './config/app.config';
import authConfig from './config/auth.config'; import authConfig from './config/auth.config';
import cspConfig from './config/csp.config'; import cspConfig from './config/csp.config';
import customizationConfig from './config/customization.config'; import customizationConfig from './config/customization.config';
import databaseConfig, { import databaseConfig, {
PostgresDatabaseConfig,
getKnexConfig, getKnexConfig,
PostgresDatabaseConfig,
} from './config/database.config'; } from './config/database.config';
import externalConfig from './config/external-services.config'; import externalConfig from './config/external-services.config';
import mediaConfig from './config/media.config'; import mediaConfig from './config/media.config';
@ -30,13 +30,11 @@ import { eventModuleConfig } from './events';
import { FrontendConfigModule } from './frontend-config/frontend-config.module'; import { FrontendConfigModule } from './frontend-config/frontend-config.module';
import { FrontendConfigService } from './frontend-config/frontend-config.service'; import { FrontendConfigService } from './frontend-config/frontend-config.service';
import { GroupsModule } from './groups/groups.module'; import { GroupsModule } from './groups/groups.module';
import { HistoryModule } from './history/history.module';
import { KnexLoggerService } from './logger/knex-logger.service'; import { KnexLoggerService } from './logger/knex-logger.service';
import { LoggerModule } from './logger/logger.module'; import { LoggerModule } from './logger/logger.module';
import { MediaRedirectModule } from './media-redirect/media-redirect.module'; import { MediaRedirectModule } from './media-redirect/media-redirect.module';
import { MediaModule } from './media/media.module'; import { MediaModule } from './media/media.module';
import { MonitoringModule } from './monitoring/monitoring.module'; import { MonitoringModule } from './monitoring/monitoring.module';
import { NotesModule } from './notes/notes.module';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
import { WebsocketModule } from './realtime/websocket/websocket.module'; import { WebsocketModule } from './realtime/websocket/websocket.module';
import { RevisionsModule } from './revisions/revisions.module'; import { RevisionsModule } from './revisions/revisions.module';
@ -97,13 +95,11 @@ const routes: Routes = [
}), }),
EventEmitterModule.forRoot(eventModuleConfig), EventEmitterModule.forRoot(eventModuleConfig),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
NotesModule, AliasModule,
UsersModule, UsersModule,
RevisionsModule, RevisionsModule,
AuthorsModule,
PublicApiModule, PublicApiModule,
PrivateApiModule, PrivateApiModule,
HistoryModule,
MonitoringModule, MonitoringModule,
PermissionsModule, PermissionsModule,
GroupsModule, GroupsModule,

View file

@ -4,23 +4,17 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; 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 { LoggerModule } from '../logger/logger.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { Identity } from './identity.entity';
import { IdentityService } from './identity.service'; import { IdentityService } from './identity.service';
import { LdapService } from './ldap/ldap.service'; import { LdapService } from './ldap/ldap.service';
import { LocalService } from './local/local.service'; import { LocalService } from './local/local.service';
import { OidcService } from './oidc/oidc.service'; import { OidcService } from './oidc/oidc.service';
@Module({ @Module({
imports: [ imports: [UsersModule, LoggerModule, KnexModule],
TypeOrmModule.forFeature([Identity, User]),
UsersModule,
LoggerModule,
],
controllers: [], controllers: [],
providers: [IdentityService, LdapService, LocalService, OidcService], providers: [IdentityService, LdapService, LocalService, OidcService],
exports: [IdentityService, LdapService, LocalService, OidcService], exports: [IdentityService, LdapService, LocalService, OidcService],

View file

@ -13,27 +13,33 @@ import {
Injectable, Injectable,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { Knex } from 'knex';
import { DataSource, Repository } from 'typeorm'; import { InjectConnection } from 'nest-knexjs';
import AuthConfiguration, { AuthConfig } from '../config/auth.config'; import AuthConfiguration, { AuthConfig } from '../config/auth.config';
import { User } from '../database/user.entity'; import {
FieldNameApiToken,
FieldNameIdentity,
FieldNameUser,
Identity,
TableIdentity,
User,
} from '../database/types';
import { NotInDBError } from '../errors/errors'; import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { UsersService } from '../users/users.service'; import { UsersService } from '../users/users.service';
import { Identity } from './identity.entity';
@Injectable() @Injectable()
export class IdentityService { export class IdentityService {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private usersService: UsersService, private usersService: UsersService,
@InjectDataSource()
private dataSource: DataSource,
@Inject(AuthConfiguration.KEY) @Inject(AuthConfiguration.KEY)
private authConfig: AuthConfig, private authConfig: AuthConfig,
@InjectRepository(Identity)
private identityRepository: Repository<Identity>, @InjectConnection()
private readonly knex: Knex,
) { ) {
this.logger.setContext(IdentityService.name); this.logger.setContext(IdentityService.name);
} }
@ -49,106 +55,148 @@ export class IdentityService {
} }
/** /**
* @async * Retrieve an identity from the information received from an auth provider.
* Retrieve an identity by userId and providerType. *
* @param {string} userId - the userId of the wanted identity * @param userId - the userId of the wanted identity
* @param {ProviderType} providerType - the providerType of the wanted identity * @param authProviderType - the providerType of the wanted identity
* @param {string} providerIdentifier - optional name of the provider if multiple exist * @param authProviderIdentifier - optional name of the provider if multiple exist
* @return
*/ */
async getIdentityFromUserIdAndProviderType( async getIdentityFromUserIdAndProviderType(
userId: string, authProviderUserId: string,
providerType: ProviderType, authProviderType: ProviderType,
providerIdentifier?: string, authProviderIdentifier: string | null,
): Promise<Identity> { ): Promise<Identity> {
const identity = await this.identityRepository.findOne({ const identity = await this.knex(TableIdentity)
where: { .select()
providerUserId: userId, .where(FieldNameIdentity.providerUserId, authProviderUserId)
providerType, .andWhere(FieldNameIdentity.providerType, authProviderType)
providerIdentifier, .andWhere(FieldNameIdentity.providerIdentifier, authProviderIdentifier)
}, .first();
relations: ['user'], if (identity === undefined) {
}); throw new NotInDBError(
if (identity === null) { `Identity for user with authProviderUserId '${authProviderUserId}' in provider ${authProviderType} ${authProviderIdentifier} not found`,
throw new NotInDBError(`Identity for user id '${userId}' not found`); );
} }
return identity; return identity;
} }
/** /**
* @async * Creates a new generic identity.
* Create a new generic identity. *
* @param {User} user - the user the identity should be added to * @param userId - the user the identity should be added to
* @param {ProviderType} providerType - the providerType of the identity * @param authProviderType - the providerType of the identity
* @param {string} providerIdentifier - the providerIdentifier of the identity * @param authProviderIdentifier - the providerIdentifier of the identity
* @param {string} providerUserId - the userId the identity should have * @param authProviderUserId - the userId the identity should have
* @return {Identity} the new local identity * @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( async createIdentity(
user: User, userId: number,
providerType: ProviderType, authProviderType: ProviderType,
providerIdentifier: string, authProviderIdentifier: string | null,
providerUserId: string, authProviderUserId: string,
): Promise<Identity> { passwordHash?: string,
const identity = Identity.create(user, providerType, providerIdentifier); transaction?: Knex,
identity.providerUserId = providerUserId; ): Promise<void> {
return await this.identityRepository.save(identity); 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 authProviderType The type of the auth provider
* @param {PendingUserConfirmationDto} updatedUserData The updated user data from the API * @param authProviderIdentifier The identifier of the auth provider
* @param {ProviderType} authProviderType The type of the auth provider * @param authProviderUserId The id of the user in the auth system
* @param {string} authProviderIdentifier The identifier of the auth provider * @param username The new username
* @param {string} providerUserId The id of the user in the auth system * @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( async createUserWithIdentity(
sessionUserData: FullUserInfoDto,
updatedUserData: PendingUserConfirmationDto,
authProviderType: ProviderType, authProviderType: ProviderType,
authProviderIdentifier: string, authProviderIdentifier: string | null,
providerUserId: string, authProviderUserId: string,
): Promise<Identity> { username: string,
displayName: string,
email: string | null,
photoUrl: string | null,
passwordHash?: string,
): Promise<User[FieldNameUser.id]> {
return await this.knex.transaction(async (transaction) => {
const userId = await this.usersService.createUser(
username,
displayName,
email,
photoUrl,
transaction,
);
await this.createIdentity(
userId,
authProviderType,
authProviderIdentifier,
authProviderUserId,
passwordHash,
transaction,
);
return userId;
});
}
/**
* Create a user with identity from pending user confirmation data.
*
* @param sessionUserData The data we got from the authProvider itself
* @param pendingUserConfirmationData The data the user entered while confirming their account
* @param authProviderType The type of the auth provider
* @param authProviderIdentifier The identifier of the auth provider
* @param authProviderUserId The id of the user in the auth system
* @return The id of the newly created user
*/
async createUserWithIdentityFromPendingUserConfirmation(
sessionUserData: FullUserInfoDto,
pendingUserConfirmationData: PendingUserConfirmationDto,
authProviderType: ProviderType,
authProviderIdentifier: string | null,
authProviderUserId: string,
): Promise<User[FieldNameUser.id]> {
const profileEditsAllowed = this.authConfig.common.allowProfileEdits; const profileEditsAllowed = this.authConfig.common.allowProfileEdits;
const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername; const chooseUsernameAllowed = this.authConfig.common.allowChooseUsername;
const username = ( const username = chooseUsernameAllowed
chooseUsernameAllowed ? pendingUserConfirmationData.username
? updatedUserData.username : sessionUserData.username;
: sessionUserData.username
) as Lowercase<string>;
const displayName = profileEditsAllowed const displayName = profileEditsAllowed
? updatedUserData.displayName ? pendingUserConfirmationData.displayName
: sessionUserData.displayName; : sessionUserData.displayName;
const photoUrl = profileEditsAllowed const photoUrl = profileEditsAllowed
? updatedUserData.profilePicture ? pendingUserConfirmationData.profilePicture
: sessionUserData.photoUrl; : sessionUserData.photoUrl;
const queryRunner = this.dataSource.createQueryRunner(); return await this.createUserWithIdentity(
await queryRunner.startTransaction(); authProviderType,
try { authProviderIdentifier,
const user = await this.usersService.createUser( authProviderUserId,
username, username,
displayName, displayName,
sessionUserData.email, sessionUserData.email,
photoUrl, 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();
}
} }
} }

View file

@ -5,7 +5,6 @@
*/ */
import { ProviderType } from '@hedgedoc/commons'; import { ProviderType } from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { import {
OptionsGraph, OptionsGraph,
OptionsType, OptionsType,
@ -20,10 +19,16 @@ import {
dictionary as zxcvbnEnDictionary, dictionary as zxcvbnEnDictionary,
translations as zxcvbnEnTranslations, translations as zxcvbnEnTranslations,
} from '@zxcvbn-ts/language-en'; } 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 authConfiguration, { AuthConfig } from '../../config/auth.config';
import { User } from '../../database/user.entity'; import {
FieldNameIdentity,
Identity,
TableIdentity,
User,
} from '../../database/types';
import { import {
InvalidCredentialsError, InvalidCredentialsError,
NoLocalIdentityError, NoLocalIdentityError,
@ -31,7 +36,6 @@ import {
} from '../../errors/errors'; } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { checkPassword, hashPassword } from '../../utils/password'; import { checkPassword, hashPassword } from '../../utils/password';
import { Identity } from '../identity.entity';
import { IdentityService } from '../identity.service'; import { IdentityService } from '../identity.service';
@Injectable() @Injectable()
@ -39,8 +43,10 @@ export class LocalService {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private identityService: IdentityService, private identityService: IdentityService,
@InjectRepository(Identity)
private identityRepository: Repository<Identity>, @InjectConnection()
private readonly knex: Knex,
@Inject(authConfiguration.KEY) @Inject(authConfiguration.KEY)
private authConfig: AuthConfig, private authConfig: AuthConfig,
) { ) {
@ -57,76 +63,83 @@ export class LocalService {
} }
/** /**
* @async
* Create a new identity for internal auth * Create a new identity for internal auth
* @param {User} user - the user the identity should be added to *
* @param userId - the user the identity should be added to
* @param {string} password - the password the identity should have * @param {string} password - the password the identity should have
* @return {Identity} the new local identity * @return {Identity} the new local identity
*/ */
async createLocalIdentity(user: User, password: string): Promise<Identity> { async createLocalIdentity(
const identity = Identity.create(user, ProviderType.LOCAL, null); username: string,
identity.passwordHash = await hashPassword(password); password: string,
identity.providerUserId = user.username; displayName: string,
return await this.identityRepository.save(identity); ): Promise<User[FieldNameUser.id]> {
const passwordHash = await hashPassword(password);
return await this.identityService.createUserWithIdentity(
ProviderType.LOCAL,
null,
username,
username,
displayName,
null,
null,
passwordHash,
);
} }
/** /**
* @async * @async
* Update the internal password of the specified the user * 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 * @param {string} newPassword - the new password
* @throws {NoLocalIdentityError} the specified user has no internal identity * @throws {NoLocalIdentityError} the specified user has no internal identity
* @return {Identity} the changed identity * @return {Identity} the changed identity
*/ */
async updateLocalPassword( async updateLocalPassword(
user: User, userId: number,
newPassword: string, newPassword: string,
): Promise<Identity> { ): Promise<void> {
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.');
}
await this.checkPasswordStrength(newPassword); await this.checkPasswordStrength(newPassword);
internalIdentity.passwordHash = await hashPassword(newPassword); const newPasswordHash = await hashPassword(newPassword);
return await this.identityRepository.save(internalIdentity); await this.knex(TableIdentity)
.update({
[FieldNameIdentity.passwordHash]: newPasswordHash,
})
.where(FieldNameIdentity.providerType, ProviderType.LOCAL)
.andWhere(FieldNameIdentity.userId, userId);
} }
/** /**
* @async * @async
* Checks if the user and password combination matches * 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 * @param {string} password - the password to use
* @throws {InvalidCredentialsError} the password and user do not match * @throws {InvalidCredentialsError} the password and user do not match
* @throws {NoLocalIdentityError} the specified user has no internal identity * @throws {NoLocalIdentityError} the specified user has no internal identity
*/ */
async checkLocalPassword(user: User, password: string): Promise<void> { async checkLocalPassword(
const internalIdentity: Identity | undefined = username: string,
password: string,
): Promise<Identity> {
const identity =
await this.identityService.getIdentityFromUserIdAndProviderType( await this.identityService.getIdentityFromUserIdAndProviderType(
user.username, username,
ProviderType.LOCAL, ProviderType.LOCAL,
null,
); );
if (internalIdentity === undefined) { if (
this.logger.debug( !(await checkPassword(
`The user with the username ${user.username} does not have an internal identity.`, password,
identity[FieldNameIdentity.passwordHash] ?? '',
))
) {
throw new InvalidCredentialsError(
'Username or password is not correct',
this.logger.getContext(),
'checkLocalPassword', '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;
} }
/** /**

View file

@ -19,9 +19,9 @@ import authConfiguration, {
AuthConfig, AuthConfig,
OidcConfig, OidcConfig,
} from '../../config/auth.config'; } from '../../config/auth.config';
import { Identity } from '../../database/types';
import { NotInDBError } from '../../errors/errors'; import { NotInDBError } from '../../errors/errors';
import { ConsoleLoggerService } from '../../logger/console-logger.service'; import { ConsoleLoggerService } from '../../logger/console-logger.service';
import { Identity } from '../identity.entity';
import { IdentityService } from '../identity.service'; import { IdentityService } from '../identity.service';
import { RequestWithSession } from '../session.guard'; import { RequestWithSession } from '../session.guard';
@ -169,12 +169,12 @@ export class OidcService {
* *
* @param {string} oidcIdentifier The identifier of the OIDC configuration * @param {string} oidcIdentifier The identifier of the OIDC configuration
* @param {RequestWithSession} request The request containing the session * @param {RequestWithSession} request The request containing the session
* @returns {FullUserInfoDto} The user information extracted from the callback * @returns {OwnUserInfoDto} The user information extracted from the callback
*/ */
async extractUserInfoFromCallback( async extractUserInfoFromCallback(
oidcIdentifier: string, oidcIdentifier: string,
request: RequestWithSession, request: RequestWithSession,
): Promise<FullUserInfoDto> { ): Promise<OwnUserInfoDto> {
const clientConfig = this.clientConfigs.get(oidcIdentifier); const clientConfig = this.clientConfigs.get(oidcIdentifier);
if (!clientConfig) { if (!clientConfig) {
throw new NotFoundException( throw new NotFoundException(

View file

@ -3,71 +3,40 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { ProviderType } from '@hedgedoc/commons';
import { GuestAccess } from '@hedgedoc/commons';
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Inject,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express';
import { CompleteRequest } from '../api/utils/request.type'; 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 { 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. * 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 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 * @throws UnauthorizedException
*/ */
@Injectable() @Injectable()
export class SessionGuard implements CanActivate { export class SessionGuard implements CanActivate {
constructor( constructor(private readonly logger: ConsoleLoggerService) {
private readonly logger: ConsoleLoggerService,
private userService: UsersService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
) {
this.logger.setContext(SessionGuard.name); this.logger.setContext(SessionGuard.name);
} }
async canActivate(context: ExecutionContext): Promise<boolean> { canActivate(context: ExecutionContext): boolean {
const request: CompleteRequest = context.switchToHttp().getRequest(); const request: CompleteRequest = context.switchToHttp().getRequest();
const username = request.session?.username; const userId = request.session?.userId;
if (!username) { const authProviderType = request.session?.authProviderType;
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) { if (!userId || !authProviderType) {
if (!request.session.authProviderType) {
request.session.authProviderType = ProviderType.GUEST;
}
return true;
}
this.logger.debug('The user has no session.'); this.logger.debug('The user has no session.');
throw new UnauthorizedException("You're not logged in"); throw new UnauthorizedException("You're not logged in");
} }
try { request.userId = userId;
request.user = await this.userService.getUserByUsername(username); request.authProviderType = authProviderType;
return true; 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;
}
} }
} }

View file

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

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { GuestAccess } from '@hedgedoc/commons'; import { PermissionLevel } from '@hedgedoc/commons';
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config'; import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
import { ConfigFactory } from '@nestjs/config/dist/interfaces'; import { ConfigFactory } from '@nestjs/config/dist/interfaces';
@ -20,7 +20,7 @@ export function createDefaultMockNoteConfig(): NoteConfig {
loggedIn: DefaultAccessLevel.WRITE, loggedIn: DefaultAccessLevel.WRITE,
}, },
}, },
guestAccess: GuestAccess.CREATE, guestAccess: PermissionLevel.CREATE,
revisionRetentionDays: 0, revisionRetentionDays: 0,
}; };
} }

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { GuestAccess } from '@hedgedoc/commons'; import { PermissionLevel } from '@hedgedoc/commons';
import mockedEnv from 'mocked-env'; import mockedEnv from 'mocked-env';
import { DefaultAccessLevel } from './default-access-level.enum'; import { DefaultAccessLevel } from './default-access-level.enum';
@ -17,7 +17,7 @@ describe('noteConfig', () => {
const negativeMaxDocumentLength = -123; const negativeMaxDocumentLength = -123;
const floatMaxDocumentLength = 2.71; const floatMaxDocumentLength = 2.71;
const invalidMaxDocumentLength = 'not-a-max-document-length'; const invalidMaxDocumentLength = 'not-a-max-document-length';
const guestAccess = GuestAccess.CREATE; const guestAccess = PermissionLevel.CREATE;
const wrongDefaultPermission = 'wrong'; const wrongDefaultPermission = 'wrong';
const retentionDays = 30; const retentionDays = 30;
@ -221,7 +221,7 @@ describe('noteConfig', () => {
DefaultAccessLevel.WRITE, DefaultAccessLevel.WRITE,
); );
expect(config.guestAccess).toEqual(GuestAccess.WRITE); expect(config.guestAccess).toEqual(PermissionLevel.WRITE);
restore(); restore();
}); });

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { GuestAccess } from '@hedgedoc/commons'; import { PermissionLevel } from '@hedgedoc/commons';
import { registerAs } from '@nestjs/config'; import { registerAs } from '@nestjs/config';
import z from 'zod'; import z from 'zod';
@ -31,9 +31,9 @@ const schema = z.object({
.default(100000) .default(100000)
.describe('HD_MAX_DOCUMENT_LENGTH'), .describe('HD_MAX_DOCUMENT_LENGTH'),
guestAccess: z guestAccess: z
.nativeEnum(GuestAccess) .nativeEnum(PermissionLevel)
.optional() .optional()
.default(GuestAccess.WRITE) .default(PermissionLevel.WRITE)
.describe('HD_GUEST_ACCESS'), .describe('HD_GUEST_ACCESS'),
permissions: z.object({ permissions: z.object({
default: z.object({ default: z.object({
@ -63,7 +63,7 @@ export type NoteConfig = z.infer<typeof schema>;
function checkEveryoneConfigIsConsistent(config: NoteConfig): void { function checkEveryoneConfigIsConsistent(config: NoteConfig): void {
const everyoneDefaultSet = const everyoneDefaultSet =
process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined; process.env.HD_PERMISSIONS_DEFAULT_EVERYONE !== undefined;
if (config.guestAccess === GuestAccess.DENY && everyoneDefaultSet) { if (config.guestAccess === PermissionLevel.DENY && everyoneDefaultSet) {
throw new Error( 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'.`, `'HD_GUEST_ACCESS' is set to '${config.guestAccess}', but 'HD_PERMISSIONS_DEFAULT_EVERYONE' is also configured. Please remove 'HD_PERMISSIONS_DEFAULT_EVERYONE'.`,
); );

View file

@ -3,10 +3,9 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { NoteType } from '@hedgedoc/commons'; import { AuthProviderType, NoteType } from '@hedgedoc/commons';
import type { Knex } from 'knex'; import type { Knex } from 'knex';
import { ProviderType } from '../../auth/provider-type.enum';
import { SpecialGroup } from '../../groups/groups.special'; import { SpecialGroup } from '../../groups/groups.special';
import { BackendType } from '../../media/backends/backend-type.enum'; import { BackendType } from '../../media/backends/backend-type.enum';
import { import {
@ -45,12 +44,14 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TableUser, (table) => { await knex.schema.createTable(TableUser, (table) => {
table.increments(FieldNameUser.id).primary(); table.increments(FieldNameUser.id).primary();
table.string(FieldNameUser.username).nullable().unique(); table.string(FieldNameUser.username).nullable().unique();
table.string(FieldNameUser.displayName).nullable(); table.string(FieldNameUser.displayName).notNullable();
table.string(FieldNameUser.photoUrl).nullable(); table.string(FieldNameUser.photoUrl).nullable();
table.string(FieldNameUser.email).nullable(); table.string(FieldNameUser.email).nullable();
table.integer(FieldNameUser.authorStyle).notNullable(); table.integer(FieldNameUser.authorStyle).notNullable();
table.uuid(FieldNameUser.guestUuid).nullable().unique(); 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 // Create group table
@ -79,7 +80,9 @@ export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable(TableNote, (table) => { await knex.schema.createTable(TableNote, (table) => {
table.increments(FieldNameNote.id).primary(); table.increments(FieldNameNote.id).primary();
table.integer(FieldNameNote.version).notNullable().defaultTo(2); 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 table
.integer(FieldNameNote.ownerId) .integer(FieldNameNote.ownerId)
.unsigned() .unsigned()
@ -88,10 +91,10 @@ export async function up(knex: Knex): Promise<void> {
.inTable(TableUser); .inTable(TableUser);
}); });
// Create alias table // Create aliases table
await knex.schema.createTable(TableAlias, (table) => { await knex.schema.createTable(TableAlias, (table) => {
table.comment( 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.string(FieldNameAlias.alias).primary();
table table
@ -118,8 +121,11 @@ export async function up(knex: Knex): Promise<void> {
.inTable(TableUser); .inTable(TableUser);
table.string(FieldNameApiToken.label).notNullable(); table.string(FieldNameApiToken.label).notNullable();
table.string(FieldNameApiToken.secretHash).notNullable(); table.string(FieldNameApiToken.secretHash).notNullable();
table.timestamp(FieldNameApiToken.validUntil).notNullable(); table
table.timestamp(FieldNameApiToken.lastUsedAt).nullable(); .timestamp(FieldNameApiToken.validUntil, { useTz: true })
.notNullable();
table.timestamp(FieldNameApiToken.lastUsedAt, { useTz: true }).nullable();
table.timestamp(FieldNameApiToken.createdAt, { useTz: true }).notNullable();
}); });
// Create identity table // Create identity table
@ -132,7 +138,7 @@ export async function up(knex: Knex): Promise<void> {
.inTable(TableUser); .inTable(TableUser);
table.enu( table.enu(
FieldNameIdentity.providerType, FieldNameIdentity.providerType,
[ProviderType.LDAP, ProviderType.LOCAL, ProviderType.OIDC], // ProviderType.GUEST is not relevant for the DB [AuthProviderType.LDAP, AuthProviderType.LOCAL, AuthProviderType.OIDC], // ProviderType.GUEST is not relevant for the DB
{ {
useNative: true, useNative: true,
enumName: FieldNameIdentity.providerType, enumName: FieldNameIdentity.providerType,
@ -141,8 +147,12 @@ export async function up(knex: Knex): Promise<void> {
table.string(FieldNameIdentity.providerIdentifier).nullable(); table.string(FieldNameIdentity.providerIdentifier).nullable();
table.string(FieldNameIdentity.providerUserId).nullable(); table.string(FieldNameIdentity.providerUserId).nullable();
table.string(FieldNameIdentity.passwordHash).nullable(); table.string(FieldNameIdentity.passwordHash).nullable();
table.timestamp(FieldNameIdentity.createdAt).defaultTo(knex.fn.now()); table
table.timestamp(FieldNameIdentity.updatedAt).defaultTo(knex.fn.now()); .timestamp(FieldNameIdentity.createdAt, { useTz: true })
.defaultTo(knex.fn.now());
table
.timestamp(FieldNameIdentity.updatedAt, { useTz: true })
.defaultTo(knex.fn.now());
table.unique( table.unique(
[ [
FieldNameIdentity.userId, FieldNameIdentity.userId,
@ -175,7 +185,7 @@ export async function up(knex: Knex): Promise<void> {
// Create revision table // Create revision table
await knex.schema.createTable(TableRevision, (table) => { await knex.schema.createTable(TableRevision, (table) => {
table.increments(FieldNameRevision.id).primary(); table.uuid(FieldNameRevision.uuid).primary();
table table
.integer(FieldNameRevision.noteId) .integer(FieldNameRevision.noteId)
.unsigned() .unsigned()
@ -191,34 +201,42 @@ export async function up(knex: Knex): Promise<void> {
useNative: true, useNative: true,
enumName: FieldNameRevision.noteType, 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 // Create revision_tag table
await knex.schema.createTable(TableRevisionTag, (table) => { await knex.schema.createTable(TableRevisionTag, (table) => {
table table
.integer(FieldNameRevisionTag.revisionId) .uuid(FieldNameRevisionTag.revisionUuid)
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameRevision.id) .references(FieldNameRevision.uuid)
.onDelete('CASCADE')
.inTable(TableRevision); .inTable(TableRevision);
table.string(FieldNameRevisionTag.tag).notNullable(); table.string(FieldNameRevisionTag.tag).notNullable();
table.primary([FieldNameRevisionTag.revisionId, FieldNameRevisionTag.tag]); table.primary([
FieldNameRevisionTag.revisionUuid,
FieldNameRevisionTag.tag,
]);
}); });
// Create authorship_info table // Create authorship_info table
await knex.schema.createTable(TableAuthorshipInfo, (table) => { await knex.schema.createTable(TableAuthorshipInfo, (table) => {
table table
.integer(FieldNameAuthorshipInfo.revisionId) .uuid(FieldNameAuthorshipInfo.revisionUuid)
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameRevision.id) .references(FieldNameRevision.uuid)
.onDelete('CASCADE')
.inTable(TableRevision); .inTable(TableRevision);
table table
.integer(FieldNameAuthorshipInfo.authorId) .integer(FieldNameAuthorshipInfo.authorId)
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameUser.id) .references(FieldNameUser.id)
.onDelete('CASCADE')
.inTable(TableUser); .inTable(TableUser);
table table
.integer(FieldNameAuthorshipInfo.startPosition) .integer(FieldNameAuthorshipInfo.startPosition)
@ -234,12 +252,14 @@ export async function up(knex: Knex): Promise<void> {
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameNote.id) .references(FieldNameNote.id)
.onDelete('CASCADE')
.inTable(TableNote); .inTable(TableNote);
table table
.integer(FieldNameNoteUserPermission.userId) .integer(FieldNameNoteUserPermission.userId)
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameUser.id) .references(FieldNameUser.id)
.onDelete('CASCADE')
.inTable(TableUser); .inTable(TableUser);
table table
.boolean(FieldNameNoteUserPermission.canEdit) .boolean(FieldNameNoteUserPermission.canEdit)
@ -258,12 +278,14 @@ export async function up(knex: Knex): Promise<void> {
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameNote.id) .references(FieldNameNote.id)
.onDelete('CASCADE')
.inTable(TableNote); .inTable(TableNote);
table table
.integer(FieldNameNoteGroupPermission.groupId) .integer(FieldNameNoteGroupPermission.groupId)
.unsigned() .unsigned()
.notNullable() .notNullable()
.references(FieldNameGroup.id) .references(FieldNameGroup.id)
.onDelete('CASCADE')
.inTable(TableGroup); .inTable(TableGroup);
table table
.boolean(FieldNameNoteGroupPermission.canEdit) .boolean(FieldNameNoteGroupPermission.canEdit)
@ -308,7 +330,9 @@ export async function up(knex: Knex): Promise<void> {
) )
.notNullable(); .notNullable();
table.text(FieldNameMediaUpload.backendData).nullable(); 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 // Create user_pinned_note table

View file

@ -36,6 +36,10 @@ export async function seed(knex: Knex): Promise<void> {
await knex(TableNoteGroupPermission).del(); await knex(TableNoteGroupPermission).del();
await knex(TableNoteUserPermission).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 guestNoteAlias = 'guest-note';
const userNoteAlias = 'user-note'; const userNoteAlias = 'user-note';
const userSlideAlias = 'user-slide'; const userSlideAlias = 'user-slide';
@ -94,6 +98,7 @@ export async function seed(knex: Knex): Promise<void> {
]); ]);
await knex(TableRevision).insert([ await knex(TableRevision).insert([
{ {
[FieldNameRevision.uuid]: guestNoteRevisionUuid,
[FieldNameRevision.noteId]: 1, [FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch( [FieldNameRevision.patch]: createPatch(
guestNoteAlias, guestNoteAlias,
@ -107,6 +112,7 @@ export async function seed(knex: Knex): Promise<void> {
[FieldNameRevision.description]: guestNoteDescription, [FieldNameRevision.description]: guestNoteDescription,
}, },
{ {
[FieldNameRevision.uuid]: userNoteRevisionUuid,
[FieldNameRevision.noteId]: 1, [FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch( [FieldNameRevision.patch]: createPatch(
userNoteAlias, userNoteAlias,
@ -120,6 +126,7 @@ export async function seed(knex: Knex): Promise<void> {
[FieldNameRevision.description]: userNoteDescription, [FieldNameRevision.description]: userNoteDescription,
}, },
{ {
[FieldNameRevision.uuid]: userSlideRevisionUuid,
[FieldNameRevision.noteId]: 1, [FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch( [FieldNameRevision.patch]: createPatch(
userSlideAlias, userSlideAlias,
@ -135,33 +142,33 @@ export async function seed(knex: Knex): Promise<void> {
]); ]);
await knex(TableRevisionTag).insert([ await knex(TableRevisionTag).insert([
...guestNoteTags.map((tag) => ({ ...guestNoteTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 1, [FieldNameRevisionTag.revisionUuid]: guestNoteRevisionUuid,
[FieldNameRevisionTag.tag]: tag, [FieldNameRevisionTag.tag]: tag,
})), })),
...userNoteTags.map((tag) => ({ ...userNoteTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 2, [FieldNameRevisionTag.revisionUuid]: userNoteRevisionUuid,
[FieldNameRevisionTag.tag]: tag, [FieldNameRevisionTag.tag]: tag,
})), })),
...userSlideTags.map((tag) => ({ ...userSlideTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 3, [FieldNameRevisionTag.revisionUuid]: userSlideRevisionUuid,
[FieldNameRevisionTag.tag]: tag, [FieldNameRevisionTag.tag]: tag,
})), })),
]); ]);
await knex(TableAuthorshipInfo).insert([ await knex(TableAuthorshipInfo).insert([
{ {
[FieldNameAuthorshipInfo.revisionId]: 1, [FieldNameAuthorshipInfo.revisionUuid]: guestNoteRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 1, [FieldNameAuthorshipInfo.authorId]: 1,
[FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: guestNoteContent.length, [FieldNameAuthorshipInfo.endPosition]: guestNoteContent.length,
}, },
{ {
[FieldNameAuthorshipInfo.revisionId]: 2, [FieldNameAuthorshipInfo.revisionUuid]: userNoteRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 2, [FieldNameAuthorshipInfo.authorId]: 2,
[FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: userNoteContent.length, [FieldNameAuthorshipInfo.endPosition]: userNoteContent.length,
}, },
{ {
[FieldNameAuthorshipInfo.revisionId]: 3, [FieldNameAuthorshipInfo.revisionUuid]: userSlideRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 2, [FieldNameAuthorshipInfo.authorId]: 2,
[FieldNameAuthorshipInfo.startPosition]: 0, [FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: userSlideContent.length, [FieldNameAuthorshipInfo.endPosition]: userSlideContent.length,

View file

@ -29,4 +29,5 @@ export enum FieldNameAlias {
export const TableAlias = 'alias'; export const TableAlias = 'alias';
export type TypeInsertAlias = Alias;
export type TypeUpdateAlias = Pick<Alias, FieldNameAlias.isPrimary>; export type TypeUpdateAlias = Pick<Alias, FieldNameAlias.isPrimary>;

View file

@ -25,6 +25,9 @@ export interface ApiToken {
/** Expiry date of the token */ /** Expiry date of the token */
[FieldNameApiToken.validUntil]: Date; [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 */ /** When the token was last used. When it was never used yet, this field is null */
[FieldNameApiToken.lastUsedAt]: Date | null; [FieldNameApiToken.lastUsedAt]: Date | null;
} }
@ -35,6 +38,7 @@ export enum FieldNameApiToken {
label = 'label', label = 'label',
secretHash = 'secret_hash', secretHash = 'secret_hash',
validUntil = 'valid_until', validUntil = 'valid_until',
createdAt = 'created_at',
lastUsedAt = 'last_used_at', lastUsedAt = 'last_used_at',
} }

View file

@ -11,7 +11,7 @@
*/ */
export interface AuthorshipInfo { export interface AuthorshipInfo {
/** The id of the {@link Revision} this belongs to. */ /** The id of the {@link Revision} this belongs to. */
[FieldNameAuthorshipInfo.revisionId]: number; [FieldNameAuthorshipInfo.revisionUuid]: string;
/** The id of the author of the edit. */ /** The id of the author of the edit. */
[FieldNameAuthorshipInfo.authorId]: number; [FieldNameAuthorshipInfo.authorId]: number;
@ -24,7 +24,7 @@ export interface AuthorshipInfo {
} }
export enum FieldNameAuthorshipInfo { export enum FieldNameAuthorshipInfo {
revisionId = 'revision_id', revisionUuid = 'revision_id',
authorId = 'author_id', authorId = 'author_id',
startPosition = 'start_position', startPosition = 'start_position',
endPosition = 'end_position', endPosition = 'end_position',

View file

@ -5,7 +5,7 @@
*/ */
import { Knex } from 'knex'; import { Knex } from 'knex';
import { Alias, TypeUpdateAlias } from './alias'; import { Alias, TypeInsertAlias, TypeUpdateAlias } from './alias';
import { ApiToken, TypeInsertApiToken, TypeUpdateApiToken } from './api-token'; import { ApiToken, TypeInsertApiToken, TypeUpdateApiToken } from './api-token';
import { Group, TypeInsertGroup, TypeUpdateGroup } from './group'; import { Group, TypeInsertGroup, TypeUpdateGroup } from './group';
import { Identity, TypeInsertIdentity, TypeUpdateIdentity } from './identity'; import { Identity, TypeInsertIdentity, TypeUpdateIdentity } from './identity';
@ -49,7 +49,11 @@ import { TypeInsertUser, TypeUpdateUser, User } from './user';
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
declare module 'knex/types/tables.js' { declare module 'knex/types/tables.js' {
interface Tables { interface Tables {
[TableAlias]: Knex.CompositeTableType<Alias, Alias, TypeUpdateAlias>; [TableAlias]: Knex.CompositeTableType<
Alias,
TypeInsertAlias,
TypeUpdateAlias
>;
[TableApiToken]: Knex.CompositeTableType< [TableApiToken]: Knex.CompositeTableType<
ApiToken, ApiToken,
TypeInsertApiToken, TypeInsertApiToken,

View file

@ -8,14 +8,14 @@
*/ */
export interface RevisionTag { export interface RevisionTag {
/** The id of {@link Revision} the {@link RevisionTag Tags} are asspcoated with. */ /** The id of {@link Revision} the {@link RevisionTag Tags} are asspcoated with. */
[FieldNameRevisionTag.revisionId]: number; [FieldNameRevisionTag.revisionUuid]: string;
/** The {@link RevisionTag Tag} text. */ /** The {@link RevisionTag Tag} text. */
[FieldNameRevisionTag.tag]: string; [FieldNameRevisionTag.tag]: string;
} }
export enum FieldNameRevisionTag { export enum FieldNameRevisionTag {
revisionId = 'revision_id', revisionUuid = 'revision_id',
tag = 'tag', tag = 'tag',
} }

View file

@ -10,7 +10,7 @@ import { NoteType } from '@hedgedoc/commons';
*/ */
export interface Revision { export interface Revision {
/** The unique id of the revision for internal referencing */ /** 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 */ /** The id of the note that this revision belongs to */
[FieldNameRevision.noteId]: number; [FieldNameRevision.noteId]: number;
@ -38,7 +38,7 @@ export interface Revision {
} }
export enum FieldNameRevision { export enum FieldNameRevision {
id = 'id', uuid = 'uuid',
noteId = 'note_id', noteId = 'note_id',
patch = 'patch', patch = 'patch',
content = 'content', content = 'content',
@ -51,7 +51,4 @@ export enum FieldNameRevision {
export const TableRevision = 'revision'; export const TableRevision = 'revision';
export type TypeInsertRevision = Omit< export type TypeInsertRevision = Omit<Revision, FieldNameRevision.createdAt>;
Revision,
FieldNameRevision.createdAt | FieldNameRevision.id
>;

View file

@ -3,7 +3,6 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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. * 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; [FieldNameUser.id]: number;
/** The user's chosen username or null if it is a guest user */ /** 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 */ /** The guest user's UUID or null if it is a registered user */
[FieldNameUser.guestUuid]: string | null; [FieldNameUser.guestUuid]: string | null;
/** The user's chosen display name */ /** The user's chosen display name */
[FieldNameUser.displayName]: string | null; [FieldNameUser.displayName]: string;
/** Timestamp when the user was created */ /** Timestamp when the user was created */
[FieldNameUser.createdAt]: Date; [FieldNameUser.createdAt]: Date;

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -9,6 +9,7 @@ import {
Catch, Catch,
ConflictException, ConflictException,
ForbiddenException, ForbiddenException,
HttpServer,
InternalServerErrorException, InternalServerErrorException,
NotFoundException, NotFoundException,
PayloadTooLargeException, PayloadTooLargeException,
@ -17,6 +18,8 @@ import {
import { HttpException } from '@nestjs/common/exceptions/http.exception'; import { HttpException } from '@nestjs/common/exceptions/http.exception';
import { BaseExceptionFilter } from '@nestjs/core'; import { BaseExceptionFilter } from '@nestjs/core';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { ErrorWithContextDetails } from './errors';
import { import {
buildHttpExceptionObject, buildHttpExceptionObject,
HttpExceptionObject, HttpExceptionObject,
@ -84,14 +87,28 @@ const mapOfHedgeDocErrorsToHttpErrors: Map<string, HttpExceptionConstructor> =
@Catch() @Catch()
export class ErrorExceptionMapping extends BaseExceptionFilter<Error> { export class ErrorExceptionMapping extends BaseExceptionFilter<Error> {
catch(error: Error, host: ArgumentsHost): void { private readonly loggerService: ConsoleLoggerService;
super.catch(ErrorExceptionMapping.transformError(error), host); 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( const httpExceptionConstructor = mapOfHedgeDocErrorsToHttpErrors.get(
error.name, error.name,
); );
if (error instanceof ErrorWithContextDetails) {
this.loggerService.error(
error.message,
undefined,
error.functionContext,
error.classContext,
);
}
if (httpExceptionConstructor === undefined) { if (httpExceptionConstructor === undefined) {
// We don't know how to map this error and just leave it be // We don't know how to map this error and just leave it be
return error; return error;

View file

@ -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 * 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'; name = 'NotInDBError';
} }
export class AlreadyInDBError extends Error { export class AlreadyInDBError extends ErrorWithContextDetails {
name = 'AlreadyInDBError'; name = 'AlreadyInDBError';
} }
export class ForbiddenIdError extends Error { export class GenericDBError extends ErrorWithContextDetails {
name = 'GenericDBError';
}
export class ForbiddenIdError extends ErrorWithContextDetails {
name = 'ForbiddenIdError'; name = 'ForbiddenIdError';
} }
export class ClientError extends Error { export class ClientError extends ErrorWithContextDetails {
name = 'ClientError'; name = 'ClientError';
} }
export class PermissionError extends Error { export class PermissionError extends ErrorWithContextDetails {
name = 'PermissionError'; name = 'PermissionError';
} }
export class TokenNotValidError extends Error { export class TokenNotValidError extends ErrorWithContextDetails {
name = 'TokenNotValidError'; name = 'TokenNotValidError';
} }
export class TooManyTokensError extends Error { export class TooManyTokensError extends ErrorWithContextDetails {
name = 'TooManyTokensError'; name = 'TooManyTokensError';
} }
export class PermissionsUpdateInconsistentError extends Error { export class PermissionsUpdateInconsistentError extends ErrorWithContextDetails {
name = 'PermissionsUpdateInconsistentError'; name = 'PermissionsUpdateInconsistentError';
} }
export class MediaBackendError extends Error { export class MediaBackendError extends ErrorWithContextDetails {
name = 'MediaBackendError'; name = 'MediaBackendError';
} }
export class PrimaryAliasDeletionForbiddenError extends Error { export class PrimaryAliasDeletionForbiddenError extends ErrorWithContextDetails {
name = 'PrimaryAliasDeletionForbiddenError'; name = 'PrimaryAliasDeletionForbiddenError';
} }
export class InvalidCredentialsError extends Error { export class InvalidCredentialsError extends ErrorWithContextDetails {
name = 'InvalidCredentialsError'; name = 'InvalidCredentialsError';
} }
export class NoLocalIdentityError extends Error { export class NoLocalIdentityError extends ErrorWithContextDetails {
name = 'NoLocalIdentityError'; name = 'NoLocalIdentityError';
} }
export class PasswordTooWeakError extends Error { export class PasswordTooWeakError extends ErrorWithContextDetails {
name = 'PasswordTooWeakError'; name = 'PasswordTooWeakError';
} }
export class MaximumDocumentLengthExceededError extends Error { export class MaximumDocumentLengthExceededError extends ErrorWithContextDetails {
name = 'MaximumDocumentLengthExceededError'; name = 'MaximumDocumentLengthExceededError';
} }
export class FeatureDisabledError extends Error { export class FeatureDisabledError extends ErrorWithContextDetails {
name = 'FeatureDisabledError'; name = 'FeatureDisabledError';
} }

View file

@ -16,8 +16,19 @@ export const eventModuleConfig = {
}; };
export enum NoteEvent { 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 { export interface NoteEventMap extends EventMap {

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { GuestAccess, ProviderType } from '@hedgedoc/commons'; import { PermissionLevel, ProviderType } from '@hedgedoc/commons';
import { ConfigModule, registerAs } from '@nestjs/config'; import { ConfigModule, registerAs } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { URL } from 'url'; import { URL } from 'url';
@ -16,7 +16,7 @@ import { ExternalServicesConfig } from '../config/external-services.config';
import { Loglevel } from '../config/loglevel.enum'; import { Loglevel } from '../config/loglevel.enum';
import { NoteConfig } from '../config/note.config'; import { NoteConfig } from '../config/note.config';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { getServerVersionFromPackageJson } from '../utils/serverVersion'; import { getServerVersionFromPackageJson } from '../utils/server-version';
import { FrontendConfigService } from './frontend-config.service'; import { FrontendConfigService } from './frontend-config.service';
/* eslint-disable /* eslint-disable
@ -108,7 +108,7 @@ describe('FrontendConfigService', () => {
return { return {
forbiddenNoteIds: [], forbiddenNoteIds: [],
maxDocumentLength: 200, maxDocumentLength: 200,
guestAccess: GuestAccess.CREATE, guestAccess: PermissionLevel.CREATE,
permissions: { permissions: {
default: { default: {
everyone: DefaultAccessLevel.READ, everyone: DefaultAccessLevel.READ,
@ -213,7 +213,7 @@ describe('FrontendConfigService', () => {
const noteConfig: NoteConfig = { const noteConfig: NoteConfig = {
forbiddenNoteIds: [], forbiddenNoteIds: [],
maxDocumentLength: maxDocumentLength, maxDocumentLength: maxDocumentLength,
guestAccess: GuestAccess.CREATE, guestAccess: PermissionLevel.CREATE,
permissions: { permissions: {
default: { default: {
everyone: DefaultAccessLevel.READ, everyone: DefaultAccessLevel.READ,

View file

@ -23,7 +23,7 @@ import externalServicesConfiguration, {
} from '../config/external-services.config'; } from '../config/external-services.config';
import noteConfiguration, { NoteConfig } from '../config/note.config'; import noteConfiguration, { NoteConfig } from '../config/note.config';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { getServerVersionFromPackageJson } from '../utils/serverVersion'; import { getServerVersionFromPackageJson } from '../utils/server-version';
@Injectable() @Injectable()
export class FrontendConfigService { export class FrontendConfigService {

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { KnexModule } from 'nest-knexjs';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Group } from './group.entity';
import { GroupsService } from './groups.service'; import { GroupsService } from './groups.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Group]), LoggerModule], imports: [LoggerModule, KnexModule],
providers: [GroupsService], providers: [GroupsService],
exports: [GroupsService], exports: [GroupsService],
}) })

View file

@ -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 * 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<Group>;
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>(GroupsService);
groupRepo = module.get<Repository<Group>>(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> => 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();
});
});
});

View file

@ -5,94 +5,94 @@
*/ */
import { GroupInfoDto } from '@hedgedoc/commons'; import { GroupInfoDto } from '@hedgedoc/commons';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { Knex } from 'knex';
import { Repository } from 'typeorm'; import { InjectConnection } from 'nest-knexjs';
import { FieldNameGroup, Group, TableGroup } from '../database/types';
import { TypeInsertGroup } from '../database/types/group';
import { AlreadyInDBError, NotInDBError } from '../errors/errors'; import { AlreadyInDBError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Group } from './group.entity';
import { SpecialGroup } from './groups.special';
@Injectable() @Injectable()
export class GroupsService { export class GroupsService {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
@InjectRepository(Group) private groupRepository: Repository<Group>,
@InjectConnection()
private readonly knex: Knex,
) { ) {
this.logger.setContext(GroupsService.name); this.logger.setContext(GroupsService.name);
} }
/** /**
* @async
* Create a new group with a given name and displayName * 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 name The group name as identifier the new group shall have
* @param special - if the group is special or not * @param displayName The display name the new group shall have
* @return {Group} the group * @throws {AlreadyInDBError} The group name is already taken
* @throws {AlreadyInDBError} the group name is already taken.
*/ */
async createGroup( async createGroup(name: string, displayName: string): Promise<void> {
name: string, const group: TypeInsertGroup = {
displayName: string, [FieldNameGroup.name]: name,
special = false, [FieldNameGroup.displayName]: displayName,
): Promise<Group> { [FieldNameGroup.isSpecial]: false,
const group = Group.create(name, displayName, special); };
try { try {
return await this.groupRepository.save(group); await this.knex(TableGroup).insert(group);
} catch { } catch {
this.logger.debug( const message = `A group with the name '${name}' already exists.`;
`A group with the name '${name}' already exists.`, this.logger.debug(message, 'createGroup');
'createGroup', throw new AlreadyInDBError(message);
);
throw new AlreadyInDBError(
`A group with the name '${name}' already exists.`,
);
} }
} }
/** /**
* @async * Fetches a group by its identifier name
* Get a group by their name. *
* @param {string} name - the groups name * @param name Name of the group to query
* @return {Group} the group * @return The group
* @throws {NotInDBError} there is no group with this name * @throws {NotInDBError} if there is no group with this name
*/ */
async getGroupByName(name: string): Promise<Group> { async getGroupByName(name: string): Promise<Group> {
const group = await this.groupRepository.findOne({ const group = await this.knex(TableGroup)
where: { name: name }, .select()
}); .where(FieldNameGroup.name, name)
if (group === null) { .first();
if (group === undefined) {
throw new NotInDBError(`Group with name '${name}' not found`); throw new NotInDBError(`Group with name '${name}' not found`);
} }
return group; return group;
} }
/** /**
* Get the group object for the everyone special group. * Fetches a groupId by its identifier name
* @return {Group} the EVERYONE group *
* @param name Name of the group to query
* @return The groupId
* @throws {NotInDBError} if there is no group with this name
*/ */
getEveryoneGroup(): Promise<Group> { async getGroupIdByName(name: string): Promise<number> {
return this.getGroupByName(SpecialGroup.EVERYONE); const group = await this.knex(TableGroup)
.select(FieldNameGroup.id)
.where(FieldNameGroup.name, name)
.first();
if (group === undefined) {
throw new NotInDBError(`Group with name '${name}' not found`);
}
return group[FieldNameGroup.id];
} }
/** /**
* Get the group object for the logged-in special group. * Builds the GroupInfoDto from a {@link Group}
* @return {Group} the LOGGED_IN group *
*/ * @param group the group to use
getLoggedInGroup(): Promise<Group> { * @return The built GroupInfoDto
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 { toGroupDto(group: Group): GroupInfoDto {
return { return {
name: group.name, name: group.name,
displayName: group.displayName, displayName: group[FieldNameGroup.displayName],
special: group.special, special: group[FieldNameGroup.isSpecial],
}; };
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HistoryEntry>;
let noteRepo: Repository<Note>;
let mockedTransaction: jest.Mock<
Promise<void>,
[(entityManager: EntityManager) => Promise<void>]
>;
class CreateQueryBuilderClass {
leftJoinAndSelect: () => CreateQueryBuilderClass;
where: () => CreateQueryBuilderClass;
orWhere: () => CreateQueryBuilderClass;
setParameter: () => CreateQueryBuilderClass;
getOne: () => HistoryEntry;
getMany: () => HistoryEntry[];
}
let createQueryBuilderFunc: CreateQueryBuilderClass;
beforeEach(async () => {
noteRepo = new Repository<Note>(
'',
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<DataSource>({
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>(HistoryService);
revisionsService = module.get<RevisionsService>(RevisionsService);
historyRepo = module.get<Repository<HistoryEntry>>(
getRepositoryToken(HistoryEntry),
);
noteRepo = module.get<Repository<Note>>(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<HistoryEntry> => 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<HistoryEntry> => 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<HistoryEntry> => 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<HistoryEntry> => {
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<HistoryEntry> => {
expect(entry).toEqual(historyEntry);
return entry;
},
)
.mockImplementationOnce(
async (entry: HistoryEntry): Promise<HistoryEntry> => {
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<HistoryEntry> => {
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<EntityManager>({
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);
});
});
});
});

View file

@ -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<HistoryEntry>,
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<HistoryEntry[]> {
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<HistoryEntry> {
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<HistoryEntry | null> {
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<HistoryEntry> {
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<void> {
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<void> {
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<void> {
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<HistoryEntry>(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<HistoryEntry>(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<HistoryEntryDto> {
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,
};
}
}

View file

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

View file

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

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -37,66 +37,90 @@ export class ConsoleLoggerService implements LoggerService {
this.classContext = context; this.classContext = context;
} }
getContext(): string | undefined {
return this.classContext;
}
setSkipColor(skipColor: boolean): void { setSkipColor(skipColor: boolean): void {
this.skipColor = skipColor; this.skipColor = skipColor;
} }
error(message: unknown, trace = '', functionContext?: string): void { error(
message: unknown,
trace = '',
functionContext?: string,
classContext?: string,
): void {
this.printMessage( this.printMessage(
message, message,
red, red,
this.makeContextString(functionContext), this.makeContextString(functionContext, classContext),
false, false,
); );
ConsoleLoggerService.printStackTrace(trace); ConsoleLoggerService.printStackTrace(trace);
} }
log(message: unknown, functionContext?: string): void { log(message: unknown, functionContext?: string, classContext?: string): void {
if (needToLog(this.appConfig.loglevel, Loglevel.INFO)) { if (needToLog(this.appConfig.loglevel, Loglevel.INFO)) {
this.printMessage( this.printMessage(
message, message,
green, green,
this.makeContextString(functionContext), this.makeContextString(functionContext, classContext),
false, false,
); );
} }
} }
warn(message: unknown, functionContext?: string): void { warn(
message: unknown,
functionContext?: string,
classContext?: string,
): void {
if (needToLog(this.appConfig.loglevel, Loglevel.WARN)) { if (needToLog(this.appConfig.loglevel, Loglevel.WARN)) {
this.printMessage( this.printMessage(
message, message,
yellow, yellow,
this.makeContextString(functionContext), this.makeContextString(functionContext, classContext),
false, false,
); );
} }
} }
debug(message: unknown, functionContext?: string): void { debug(
message: unknown,
functionContext?: string,
classContext?: string,
): void {
if (needToLog(this.appConfig.loglevel, Loglevel.DEBUG)) { if (needToLog(this.appConfig.loglevel, Loglevel.DEBUG)) {
this.printMessage( this.printMessage(
message, message,
magentaBright, magentaBright,
this.makeContextString(functionContext), this.makeContextString(functionContext, classContext),
false, false,
); );
} }
} }
verbose(message: unknown, functionContext?: string): void { verbose(
message: unknown,
functionContext?: string,
classContext?: string,
): void {
if (needToLog(this.appConfig.loglevel, Loglevel.TRACE)) { if (needToLog(this.appConfig.loglevel, Loglevel.TRACE)) {
this.printMessage( this.printMessage(
message, message,
cyanBright, cyanBright,
this.makeContextString(functionContext), this.makeContextString(functionContext, classContext),
false, false,
); );
} }
} }
private makeContextString(functionContext?: string): string { private makeContextString(
let context = this.classContext; functionContext?: string,
classContext?: string,
): string {
let context = classContext ?? this.classContext;
if (!context) { if (!context) {
context = 'HedgeDoc'; context = 'HedgeDoc';
} }

View file

@ -1,31 +1,23 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm'; import { KnexModule } from 'nest-knexjs';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { NotesModule } from '../notes/notes.module'; import { NoteModule } from '../note/note.module';
import { UsersModule } from '../users/users.module';
import { AzureBackend } from './backends/azure-backend'; import { AzureBackend } from './backends/azure-backend';
import { FilesystemBackend } from './backends/filesystem-backend'; import { FilesystemBackend } from './backends/filesystem-backend';
import { ImgurBackend } from './backends/imgur-backend'; import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend'; import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend'; import { WebdavBackend } from './backends/webdav-backend';
import { MediaUpload } from './media-upload.entity';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
@Module({ @Module({
imports: [ imports: [NoteModule, LoggerModule, ConfigModule, KnexModule],
TypeOrmModule.forFeature([MediaUpload]),
NotesModule,
UsersModule,
LoggerModule,
ConfigModule,
],
providers: [ providers: [
MediaService, MediaService,
FilesystemBackend, FilesystemBackend,

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -11,6 +11,7 @@ import { promises as fs } from 'fs';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import appConfigMock from '../../src/config/mock/app.config.mock'; import appConfigMock from '../../src/config/mock/app.config.mock';
import { AliasModule } from '../alias/alias.module';
import { ApiToken } from '../api-token/api-token.entity'; import { ApiToken } from '../api-token/api-token.entity';
import { Identity } from '../auth/identity.entity'; import { Identity } from '../auth/identity.entity';
import { Author } from '../authors/author.entity'; import { Author } from '../authors/author.entity';
@ -23,9 +24,8 @@ import { ClientError, NotInDBError } from '../errors/errors';
import { eventModuleConfig } from '../events'; import { eventModuleConfig } from '../events';
import { Group } from '../groups/group.entity'; import { Group } from '../groups/group.entity';
import { LoggerModule } from '../logger/logger.module'; 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 { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
import { NoteGroupPermission } from '../permissions/note-group-permission.entity'; import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
import { NoteUserPermission } from '../permissions/note-user-permission.entity'; import { NoteUserPermission } from '../permissions/note-user-permission.entity';
@ -77,7 +77,7 @@ describe('MediaService', () => {
], ],
}), }),
LoggerModule, LoggerModule,
NotesModule, AliasModule,
UsersModule, UsersModule,
EventEmitterModule.forRoot(eventModuleConfig), EventEmitterModule.forRoot(eventModuleConfig),
], ],
@ -317,20 +317,22 @@ describe('MediaService', () => {
} as MediaUpload; } as MediaUpload;
createQueryBuilderFunc.getMany = () => [mockMediaUploadEntry]; createQueryBuilderFunc.getMany = () => [mockMediaUploadEntry];
expect( expect(
await service.listUploadsByUser({ username: 'hardcoded' } as User), await service.getMediaUploadUuidsByUserId({
username: 'hardcoded',
} as User),
).toEqual([mockMediaUploadEntry]); ).toEqual([mockMediaUploadEntry]);
}); });
it('without uploads from user', async () => { it('without uploads from user', async () => {
createQueryBuilderFunc.getMany = () => []; createQueryBuilderFunc.getMany = () => [];
const mediaList = await service.listUploadsByUser({ const mediaList = await service.getMediaUploadUuidsByUserId({
username: username, username: username,
} as User); } as User);
expect(mediaList).toEqual([]); expect(mediaList).toEqual([]);
}); });
it('with error (null as return value of find)', async () => { it('with error (null as return value of find)', async () => {
createQueryBuilderFunc.getMany = () => []; createQueryBuilderFunc.getMany = () => [];
const mediaList = await service.listUploadsByUser({ const mediaList = await service.getMediaUploadUuidsByUserId({
username: username, username: username,
} as User); } as User);
expect(mediaList).toEqual([]); expect(mediaList).toEqual([]);
@ -364,7 +366,7 @@ describe('MediaService', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
.mockImplementation(() => createQueryBuilder); .mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({ const mediaList = await service.getMediaUploadUuidsByNoteId({
id: 123, id: 123,
} as Note); } as Note);
expect(mediaList).toEqual([mockMediaUploadEntry]); expect(mediaList).toEqual([mockMediaUploadEntry]);
@ -382,7 +384,7 @@ describe('MediaService', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
.mockImplementation(() => createQueryBuilder); .mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({ const mediaList = await service.getMediaUploadUuidsByNoteId({
id: 123, id: 123,
} as Note); } as Note);
expect(mediaList).toEqual([]); expect(mediaList).toEqual([]);
@ -399,7 +401,7 @@ describe('MediaService', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
.mockImplementation(() => createQueryBuilder); .mockImplementation(() => createQueryBuilder);
const mediaList = await service.listUploadsByNote({ const mediaList = await service.getMediaUploadUuidsByNoteId({
id: 123, id: 123,
} as Note); } as Note);
expect(mediaList).toEqual([]); expect(mediaList).toEqual([]);

View file

@ -6,16 +6,28 @@
import { MediaUploadDto } from '@hedgedoc/commons'; import { MediaUploadDto } from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { InjectRepository } from '@nestjs/typeorm';
import * as FileType from 'file-type'; 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 { v7 as uuidV7 } from 'uuid';
import mediaConfiguration, { MediaConfig } from '../config/media.config'; import mediaConfiguration, { MediaConfig } from '../config/media.config';
import { User } from '../database/user.entity'; import {
FieldNameAlias,
FieldNameMediaUpload,
FieldNameNote,
FieldNameUser,
MediaUpload,
Note,
TableAlias,
TableMediaUpload,
TableNote,
TableUser,
User,
} from '../database/types';
import { ClientError, NotInDBError } from '../errors/errors'; import { ClientError, NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service'; 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 { AzureBackend } from './backends/azure-backend';
import { BackendType } from './backends/backend-type.enum'; import { BackendType } from './backends/backend-type.enum';
import { FilesystemBackend } from './backends/filesystem-backend'; import { FilesystemBackend } from './backends/filesystem-backend';
@ -23,7 +35,6 @@ import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend'; import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend'; import { WebdavBackend } from './backends/webdav-backend';
import { MediaBackend } from './media-backend.interface'; import { MediaBackend } from './media-backend.interface';
import { MediaUpload } from './media-upload.entity';
@Injectable() @Injectable()
export class MediaService { export class MediaService {
@ -32,9 +43,13 @@ export class MediaService {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
@InjectRepository(MediaUpload) private readonly noteService: NoteService,
private mediaUploadRepository: Repository<MediaUpload>,
@InjectConnection()
private readonly knex: Knex,
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(mediaConfiguration.KEY) @Inject(mediaConfiguration.KEY)
private mediaConfig: MediaConfig, private mediaConfig: MediaConfig,
) { ) {
@ -62,34 +77,28 @@ export class MediaService {
} }
/** /**
* @async * Saves the given buffer to the configured MediaBackend and creates a MediaUploadEntity
* 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. * 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 fileName The original file name
* @param {User} user - the user who uploaded this file * @param fileBuffer The buffer with the file contents to save
* @param {Note} note - the note which will be associated with the new file. * @param userId Id of the user who uploaded this file
* @return {MediaUpload} the created MediaUpload entity * @param noteId Id of the note which will be associated with the new file
* @throws {ClientError} the MIME type of the file is not supported. * @return The created MediaUpload entity
* @throws {NotInDBError} - the note or user is not in the database * @throws {ClientError} if the MIME type of the file is not supported
* @throws {MediaBackendError} - there was an error saving the file * @throws {NotInDBError} if the note or user is not in the database
* @throws {MediaBackendError} if there was an error saving the file
*/ */
async saveFile( async saveFile(
fileName: string, fileName: string,
fileBuffer: Buffer, fileBuffer: Buffer,
user: User | null, userId: User[FieldNameUser.id],
note: Note, noteId: Note[FieldNameNote.id],
): Promise<MediaUpload> { ): Promise<MediaUpload[FieldNameMediaUpload.uuid]> {
if (user) {
this.logger.debug( this.logger.debug(
`Saving file for note '${note.id}' and user '${user.username}'`, `Saving file for note '${noteId}' and user '${userId}'`,
'saveFile', 'saveFile',
); );
} else {
this.logger.debug(
`Saving file for note '${note.id}' and not logged in user`,
'saveFile',
);
}
const fileTypeResult = await FileType.fromBuffer(fileBuffer); const fileTypeResult = await FileType.fromBuffer(fileBuffer);
if (!fileTypeResult) { if (!fileTypeResult) {
throw new ClientError('Could not detect file type.'); throw new ClientError('Could not detect file type.');
@ -103,29 +112,44 @@ export class MediaService {
fileBuffer, fileBuffer,
fileTypeResult, fileTypeResult,
); );
const mediaUpload = MediaUpload.create( const mediaUploads = await this.knex(TableMediaUpload).insert(
uuid, {
fileName, [FieldNameMediaUpload.fileName]: fileName,
note, [FieldNameMediaUpload.userId]: userId,
user, [FieldNameMediaUpload.noteId]: noteId,
this.mediaBackendType, [FieldNameMediaUpload.backendType]: this.mediaBackendType,
backendData, [FieldNameMediaUpload.backendData]: backendData,
},
[FieldNameMediaUpload.uuid],
); );
return await this.mediaUploadRepository.save(mediaUpload); return mediaUploads[0][FieldNameMediaUpload.uuid];
} }
/** /**
* @async * @async
* Try to delete the specified file. * 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 * @throws {MediaBackendError} - there was an error deleting the file
*/ */
async deleteFile(mediaUpload: MediaUpload): Promise<void> { async deleteFile(uuid: string): Promise<void> {
await this.mediaBackend.deleteFile( const backendData = await this.knex(TableMediaUpload)
mediaUpload.uuid, .select(FieldNameMediaUpload.backendData)
mediaUpload.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.mediaUploadRepository.remove(mediaUpload); }
await this.mediaBackend.deleteFile(
uuid,
backendData[FieldNameMediaUpload.backendData],
);
await this.knex(TableMediaUpload)
.where(FieldNameMediaUpload.uuid, uuid)
.delete();
} }
/** /**
@ -170,10 +194,9 @@ export class MediaService {
* @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database. * @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database.
*/ */
async findUploadByUuid(uuid: string): Promise<MediaUpload> { async findUploadByUuid(uuid: string): Promise<MediaUpload> {
const mediaUpload = await this.mediaUploadRepository.findOne({ const mediaUpload = await this.knex(TableMediaUpload)
where: { uuid }, .select()
relations: ['user'], .where(FieldNameMediaUpload.uuid, uuid);
});
if (mediaUpload === null) { if (mediaUpload === null) {
throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`); throw new NotInDBError(`MediaUpload with uuid '${uuid}' not found`);
} }
@ -183,49 +206,50 @@ export class MediaService {
/** /**
* @async * @async
* List all uploads by a specific user * 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 * @return {MediaUpload[]} arary of media uploads owned by the user
*/ */
async listUploadsByUser(user: User): Promise<MediaUpload[]> { async getMediaUploadUuidsByUserId(
const mediaUploads = await this.mediaUploadRepository userId: number,
.createQueryBuilder('media') ): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
.where('media.userId = :userId', { userId: user.id }) const results = await this.knex(TableMediaUpload)
.getMany(); .select(FieldNameMediaUpload.uuid)
if (mediaUploads === null) { .where(FieldNameMediaUpload.userId, userId);
return []; return results.map((result) => result[FieldNameMediaUpload.uuid]);
}
return mediaUploads;
} }
/** /**
* @async * @async
* List all uploads to a specific note * 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 * @return {MediaUpload[]} array of media uploads owned by the user
*/ */
async listUploadsByNote(note: Note): Promise<MediaUpload[]> { async getMediaUploadUuidsByNoteId(
const mediaUploads = await this.mediaUploadRepository noteId: number,
.createQueryBuilder('upload') ): Promise<MediaUpload[FieldNameMediaUpload.uuid][]> {
.where('upload.note = :note', { note: note.id }) return await this.knex.transaction(async (transaction) => {
.getMany(); const results = await transaction(TableMediaUpload)
if (mediaUploads === null) { .select(FieldNameMediaUpload.uuid)
return []; .where(FieldNameMediaUpload.noteId, noteId);
} return results.map((result) => result[FieldNameMediaUpload.uuid]);
return mediaUploads; });
} }
/** /**
* @async * @async
* Set the note of a mediaUpload to null * 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<void> { async removeNoteFromMediaUpload(uuid: string): Promise<void> {
this.logger.debug( this.logger.debug(
'Setting note to null for mediaUpload: ' + mediaUpload.uuid, 'Setting note to null for mediaUpload: ' + uuid,
'removeNoteFromMediaUpload', 'removeNoteFromMediaUpload',
); );
mediaUpload.note = Promise.resolve(null); await this.knex(TableMediaUpload)
await this.mediaUploadRepository.save(mediaUpload); .update({
[FieldNameMediaUpload.noteId]: null,
})
.where(FieldNameMediaUpload.uuid, uuid);
} }
private chooseBackendType(): BackendType { private chooseBackendType(): BackendType {
@ -262,14 +286,34 @@ export class MediaService {
} }
} }
async toMediaUploadDto(mediaUpload: MediaUpload): Promise<MediaUploadDto> { async getMediaUploadDtosByUuids(uuids: string[]): Promise<MediaUploadDto[]> {
const user = await mediaUpload.user; const notePrimaryAlias = this.knex(TableAlias)
return { .where(
uuid: mediaUpload.uuid, FieldNameAlias.noteId,
fileName: mediaUpload.fileName, `${TableMediaUpload}.${FieldNameMediaUpload.noteId}`,
noteId: (await mediaUpload.note)?.publicId ?? null, )
createdAt: mediaUpload.createdAt.toISOString(), .andWhere(FieldNameAlias.isPrimary, true)
username: user?.username ?? null, .select(FieldNameAlias.alias);
}; const mediaUploads = await this.knex(TableMediaUpload)
.join(
TableUser,
`${TableUser}.${FieldNameUser.id}`,
`${TableMediaUpload}.${FieldNameMediaUpload.userId}`,
)
.select(
`${TableMediaUpload}.${FieldNameMediaUpload.uuid}`,
`${TableMediaUpload}.${FieldNameMediaUpload.fileName}`,
`${TableMediaUpload}.${FieldNameMediaUpload.createdAt}`,
`${TableUser}.${FieldNameUser.username}`,
notePrimaryAlias,
)
.whereIn(FieldNameMediaUpload.uuid, uuids);
return mediaUploads.map((mediaUpload) => ({
uuid: mediaUpload[FieldNameMediaUpload.uuid],
fileName: mediaUpload[FieldNameMediaUpload.fileName],
noteId: mediaUpload[FieldNameAlias.alias],
createdAt: mediaUpload[FieldNameMediaUpload.createdAt].toISOString(),
username: mediaUpload[FieldNameUser.username],
}));
} }
} }

View file

@ -6,7 +6,7 @@
import { ServerStatusDto } from '@hedgedoc/commons'; import { ServerStatusDto } from '@hedgedoc/commons';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { getServerVersionFromPackageJson } from '../utils/serverVersion'; import { getServerVersionFromPackageJson } from '../utils/server-version';
@Injectable() @Injectable()
export class MonitoringService { export class MonitoringService {

View file

@ -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<Note>,
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
@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<Alias> {
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<Alias> {
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<Note> {
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,
};
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { KnexModule } from 'nest-knexjs';
import { GroupsModule } from '../groups/groups.module';
import { LoggerModule } from '../logger/logger.module';
import { RealtimeNoteModule } from '../realtime/realtime-note/realtime-note.module';
import { RevisionsModule } from '../revisions/revisions.module';
import { UsersModule } from '../users/users.module';
import { NoteService } from './note.service';
@Module({
imports: [
RevisionsModule,
UsersModule,
GroupsModule,
LoggerModule,
ConfigModule,
RealtimeNoteModule,
KnexModule,
],
controllers: [],
providers: [NoteService],
exports: [NoteService],
})
export class NoteModule {}

View file

@ -15,6 +15,7 @@ import {
Repository, Repository,
} from 'typeorm'; } from 'typeorm';
import { AliasService } from '../alias/alias.service';
import { ApiToken } from '../api-token/api-token.entity'; import { ApiToken } from '../api-token/api-token.entity';
import { Identity } from '../auth/identity.entity'; import { Identity } from '../auth/identity.entity';
import { Author } from '../authors/author.entity'; import { Author } from '../authors/author.entity';
@ -49,16 +50,15 @@ import { RevisionsService } from '../revisions/revisions.service';
import { Session } from '../sessions/session.entity'; import { Session } from '../sessions/session.entity';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder'; import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder';
import { Alias } from './alias.entity'; import { Alias } from './aliases.entity';
import { AliasService } from './alias.service';
import { Note } from './note.entity'; import { Note } from './note.entity';
import { NotesService } from './notes.service'; import { NoteService } from './note.service';
import { Tag } from './tag.entity'; import { Tag } from './tag.entity';
jest.mock('../revisions/revisions.service'); jest.mock('../revisions/revisions.service');
describe('NotesService', () => { describe('NotesService', () => {
let service: NotesService; let service: NoteService;
let revisionsService: RevisionsService; let revisionsService: RevisionsService;
const noteMockConfig: NoteConfig = createDefaultMockNoteConfig(); const noteMockConfig: NoteConfig = createDefaultMockNoteConfig();
let noteRepo: Repository<Note>; let noteRepo: Repository<Note>;
@ -137,7 +137,7 @@ describe('NotesService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
NotesService, NoteService,
{ {
provide: RevisionsService, provide: RevisionsService,
useValue: revisionsService, useValue: revisionsService,
@ -219,7 +219,7 @@ describe('NotesService', () => {
forbiddenNoteId = noteConfig.forbiddenNoteIds[0]; forbiddenNoteId = noteConfig.forbiddenNoteIds[0];
everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone; everyoneDefaultAccessPermission = noteConfig.permissions.default.everyone;
loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn; loggedinDefaultAccessPermission = noteConfig.permissions.default.loggedIn;
service = module.get<NotesService>(NotesService); service = module.get<NoteService>(NoteService);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note)); noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
aliasRepo = module.get<Repository<Alias>>(getRepositoryToken(Alias)); aliasRepo = module.get<Repository<Alias>>(getRepositoryToken(Alias));
eventEmitter = module.get<EventEmitter2>(EventEmitter2); eventEmitter = module.get<EventEmitter2>(EventEmitter2);
@ -377,7 +377,7 @@ describe('NotesService', () => {
mockSelectQueryBuilderInRepo(noteRepo, null); mockSelectQueryBuilderInRepo(noteRepo, null);
}); });
it('without alias, without owner', async () => { it('without aliases, without owner', async () => {
const newNote = await service.createNote(content, null); const newNote = await service.createNote(content, null);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
@ -403,7 +403,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toBeNull(); expect(await newNote.owner).toBeNull();
expect(await newNote.aliases).toHaveLength(0); 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); const newNote = await service.createNote(content, user);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.revisions).toStrictEqual([newRevision]);
@ -429,7 +429,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toEqual(user); expect(await newNote.owner).toEqual(user);
expect(await newNote.aliases).toHaveLength(0); 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); const newNote = await service.createNote(content, null, alias);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
expect(await newNote.revisions).toStrictEqual([newRevision]); expect(await newNote.revisions).toStrictEqual([newRevision]);
@ -454,7 +454,7 @@ describe('NotesService', () => {
expect(await newNote.owner).toBeNull(); expect(await newNote.owner).toBeNull();
expect(await newNote.aliases).toHaveLength(1); 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); const newNote = await service.createNote(content, user, alias);
expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content); expect(createRevisionSpy).toHaveBeenCalledWith(newNote, content);
@ -549,7 +549,7 @@ describe('NotesService', () => {
mockSelectQueryBuilderInRepo(noteRepo, null); mockSelectQueryBuilderInRepo(noteRepo, null);
}); });
it('alias is forbidden', async () => { it('aliases is forbidden', async () => {
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
await expect( await expect(
@ -557,7 +557,7 @@ describe('NotesService', () => {
).rejects.toThrow(ForbiddenIdError); ).rejects.toThrow(ForbiddenIdError);
}); });
it('alias is already used (as another alias)', async () => { it('aliases is already used (as another aliases)', async () => {
mockGroupRepo(); mockGroupRepo();
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(false);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(true); 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(); mockGroupRepo();
jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true); jest.spyOn(noteRepo, 'existsBy').mockResolvedValueOnce(true);
jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false); jest.spyOn(aliasRepo, 'existsBy').mockResolvedValueOnce(false);
@ -614,20 +614,20 @@ describe('NotesService', () => {
const user = User.create('hardcoded', 'Testy') as User; const user = User.create('hardcoded', 'Testy') as User;
const note = Note.create(user) as Note; const note = Note.create(user) as Note;
mockSelectQueryBuilderInRepo(noteRepo, note); mockSelectQueryBuilderInRepo(noteRepo, note);
const foundNote = await service.getNoteByIdOrAlias('noteThatExists'); const foundNote = await service.getNoteIdByAlias('noteThatExists');
expect(foundNote).toEqual(note); expect(foundNote).toEqual(note);
}); });
describe('fails:', () => { describe('fails:', () => {
it('no note found', async () => { it('no note found', async () => {
mockSelectQueryBuilderInRepo(noteRepo, null); mockSelectQueryBuilderInRepo(noteRepo, null);
await expect( await expect(
service.getNoteByIdOrAlias('noteThatDoesNoteExist'), service.getNoteIdByAlias('noteThatDoesNoteExist'),
).rejects.toThrow(NotInDBError); ).rejects.toThrow(NotInDBError);
}); });
it('id is forbidden', async () => { it('id is forbidden', async () => {
await expect( await expect(service.getNoteIdByAlias(forbiddenNoteId)).rejects.toThrow(
service.getNoteByIdOrAlias(forbiddenNoteId), ForbiddenIdError,
).rejects.toThrow(ForbiddenIdError); );
}); });
}); });
}); });
@ -704,7 +704,7 @@ describe('NotesService', () => {
expect(metadataDto).toMatchSnapshot(); expect(metadataDto).toMatchSnapshot();
}); });
it('returns publicId if no alias exists', async () => { it('returns publicId if no aliases exists', async () => {
const [note, ,] = await getMockData(); const [note, ,] = await getMockData();
const metadataDto = await service.toNoteMetadataDto(note); const metadataDto = await service.toNoteMetadataDto(note);
expect(metadataDto.primaryAddress).toEqual(note.publicId); expect(metadataDto.primaryAddress).toEqual(note.publicId);

View file

@ -0,0 +1,369 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
NoteDto,
NoteMetadataDto,
NotePermissionsDto,
SpecialGroup,
} from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import { AliasService } from '../alias/alias.service';
import { DefaultAccessLevel } from '../config/default-access-level.enum';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import {
FieldNameAlias,
FieldNameNote,
Note,
TableAlias,
TableNote,
User,
} from '../database/types';
import {
ForbiddenIdError,
GenericDBError,
MaximumDocumentLengthExceededError,
NotInDBError,
} from '../errors/errors';
import { NoteEventMap } from '../events';
import { GroupsService } from '../groups/groups.service';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { PermissionService } from '../permissions/permission.service';
import { RealtimeNoteStore } from '../realtime/realtime-note/realtime-note-store';
import { RealtimeNoteService } from '../realtime/realtime-note/realtime-note.service';
import { RevisionsService } from '../revisions/revisions.service';
import { UsersService } from '../users/users.service';
import { getPrimaryAlias } from './utils';
@Injectable()
export class NoteService {
constructor(
@InjectConnection()
private readonly knex: Knex,
private readonly logger: ConsoleLoggerService,
@Inject(UsersService) private usersService: UsersService,
@Inject(GroupsService) private groupsService: GroupsService,
private revisionsService: RevisionsService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
@Inject(AliasService)
private aliasService: AliasService,
@Inject(PermissionService) private permissionService: PermissionService,
private realtimeNoteService: RealtimeNoteService,
private realtimeNoteStore: RealtimeNoteStore,
private eventEmitter: EventEmitter2<NoteEventMap>,
) {
this.logger.setContext(NoteService.name);
}
/**
* Get all notes owned by a user
*
* @param userId The id of the user who owns the notes
* @return Array of notes owned by the user
*/
async getUserNotes(userId: number): Promise<Note[]> {
// noinspection ES6RedundantAwait
return await this.knex(TableNote)
.select()
.where(FieldNameNote.ownerId, userId);
}
/**
* Creates a new note
*
* @param noteContent The content of the new note, in most cases an empty string
* @param givenAlias An optional alias the note should have
* @param ownerUserId The owner of the note
* @return The newly created note
* @throws {AlreadyInDBError} a note with the requested id or aliases already exists
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
* @throws {MaximumDocumentLengthExceededError} the noteContent is longer than the maxDocumentLength
* @thorws {GenericDBError} the database returned a non-expected value
*/
async createNote(
noteContent: string,
ownerUserId: number,
givenAlias?: string,
): Promise<number> {
// Check if new note doesn't violate application constraints
if (noteContent.length > this.noteConfig.maxDocumentLength) {
throw new MaximumDocumentLengthExceededError();
}
return await this.knex.transaction(async (transaction) => {
// Create note itself in the database
const createdNotes = await transaction(TableNote).insert(
{
[FieldNameNote.ownerId]: ownerUserId,
[FieldNameNote.version]: 2,
},
[FieldNameNote.id],
);
if (createdNotes.length !== 1) {
throw new GenericDBError(
'The note could not be created in the database',
this.logger.getContext(),
'createNote',
);
}
const noteId = createdNotes[0][FieldNameNote.id];
if (givenAlias !== undefined) {
await this.aliasService.ensureAliasIsAvailable(givenAlias, transaction);
}
const newAlias =
givenAlias === undefined
? this.aliasService.generateRandomAlias()
: givenAlias;
await this.aliasService.addAlias(noteId, newAlias, transaction);
await this.revisionsService.createRevision(
noteId,
noteContent,
transaction,
);
const isUserRegistered = await this.usersService.isRegisteredUser(
ownerUserId,
transaction,
);
const everyoneAccessLevel = isUserRegistered
? // Use the default access level from the config for registered users
this.noteConfig.permissions.default.everyone
: // If the owner is a guest, this is an anonymous note
// Anonymous notes are always writeable by everyone
DefaultAccessLevel.WRITE;
const loggedInUsersAccessLevel =
this.noteConfig.permissions.default.loggedIn;
await this.permissionService.setGroupPermission(
noteId,
SpecialGroup.EVERYONE,
everyoneAccessLevel,
transaction,
);
await this.permissionService.setGroupPermission(
noteId,
SpecialGroup.LOGGED_IN,
loggedInUsersAccessLevel,
transaction,
);
return noteId;
});
}
/**
* Get the current content of the note.
* @param noteId the note to use
* @throws {NotInDBError} the note is not in the DB
* @return {string} the content of the note
*/
async getNoteContent(noteId: Note[FieldNameNote.id]): Promise<string> {
const realtimeContent = this.realtimeNoteStore
.find(noteId)
?.getRealtimeDoc()
.getCurrentContent();
if (realtimeContent) {
return realtimeContent;
}
const latestRevision =
await this.revisionsService.getLatestRevision(noteId);
return latestRevision.content;
}
/**
* Get a note by either their id or aliases.
* @param alias the notes id or aliases
* @throws {NotInDBError} there is no note with this id or aliases
* @throws {ForbiddenIdError} the requested id or aliases is forbidden
* @return the note id
*/
async getNoteIdByAlias(alias: string, transaction?: Knex): Promise<number> {
const dbActor = transaction ?? this.knex;
const isForbidden = this.aliasService.isAliasForbidden(alias);
if (isForbidden) {
throw new ForbiddenIdError(
`The note id or alias '${alias}' is forbidden by the administrator.`,
);
}
this.logger.debug(`Trying to find note '${alias}'`, 'getNoteIdByAlias');
/**
* This query gets the note's aliases, owner, groupPermissions (and the groups), userPermissions (and the users) and tags and
* then only gets the note, that either has a publicId :noteIdOrAlias or has any aliases with this name.
**/
const note = await dbActor(TableAlias)
.select<Pick<Note, FieldNameNote.id>>(`${TableNote}.${FieldNameNote.id}`)
.where(FieldNameAlias.alias, alias)
.join(
TableNote,
`${TableAlias}.${FieldNameAlias.noteId}`,
`${TableNote}.${FieldNameNote.id}`,
)
.first();
if (note === undefined) {
const message = `Could not find note '${alias}'`;
this.logger.debug(message, 'getNoteIdByAlias');
throw new NotInDBError(message);
}
this.logger.debug(`Found note '${alias}'`, 'getNoteIdByAlias');
return note[FieldNameNote.id];
}
/**
* Get all users that ever appeared as an author for the given note
* @param note The note to search authors for
*/
async getAuthorUsers(note: Note): Promise<User[]> {
// return await this.userRepository
// .createQueryBuilder('user')
// .innerJoin('user.authors', 'author')
// .innerJoin('author.edits', 'edit')
// .innerJoin('edit.revisions', 'revision')
// .innerJoin('revision.note', 'note')
// .where('note.id = :id', { id: note.id })
// .getMany();
return [];
}
/**
* Deletes a note
*
* @param noteId If of the note to delete
* @throws {NotInDBError} if there is no note with this id
*/
async deleteNote(noteId: Note[FieldNameNote.id]): Promise<void> {
const numberOfDeletedNotes = await this.knex(TableNote)
.where(FieldNameNote.id, noteId)
.delete();
if (numberOfDeletedNotes === 0) {
throw new NotInDBError(
`There is no note with the id '${noteId}' to delete.`,
);
}
}
/**
*
* Update the content of a note
*
* @param noteId - the note
* @param noteContent - the new content
* @return the note with a new revision and new content
* @throws {NotInDBError} there is no note with this id or aliases
*/
async updateNote(noteId: number, noteContent: string): Promise<void> {
// TODO Disconnect realtime clients first
await this.revisionsService.createRevision(noteId, noteContent);
// TODO Reload realtime note
}
/**
* @async
* Calculate the updateUser (for the NoteDto) for a Note.
* @param {Note} noteId - the note to use
* @return {User} user to be used as updateUser in the NoteDto
*/
async getLastUpdatedNoteUser(noteId: number): Promise<number> {
const lastRevision = await this.revisionsService.getLatestRevision(noteId);
// const edits = await lastRevision.edits;
// if (edits.length > 0) {
// // Sort the last Revisions Edits by their updatedAt Date to get the latest one
// // the user of that Edit is the updateUser
// return await (
// await edits.sort(
// (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
// )[0].author
// ).user;
// }
// // If there are no Edits, the owner is the updateUser
// return await noteId.owner;
return 0;
}
/**
* Build NotePermissionsDto from a note.
* @param {Note} note - the note to use
* @return {NotePermissionsDto} the built NotePermissionDto
*/
async toNotePermissionsDto(noteId: number): Promise<NotePermissionsDto> {
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<NoteMetadataDto> {
const updateUser = await this.getLastUpdatedNoteUser(note);
const latestRevision = await this.revisionsService.getLatestRevision(note);
return {
id: note.publicId,
aliases: await Promise.all(
(await note.aliases).map((alias) =>
this.aliasService.toAliasDto(alias, note),
),
),
primaryAddress: (await getPrimaryAlias(note)) ?? note.publicId,
title: latestRevision.title,
description: latestRevision.description,
tags: (await latestRevision.tags).map((tag) => tag.name),
createdAt: note.createdAt,
editedBy: (await this.getAuthorUsers(note)).map((user) => user.username),
permissions: await this.toNotePermissionsDto(note),
version: note.version,
updatedAt: latestRevision.createdAt,
updateUsername: updateUser ? updateUser.username : null,
viewCount: note.viewCount,
};
}
/**
* @async
* Build NoteDto from a note.
* @param {Note} note - the note to use
* @return {NoteDto} the built NoteDto
*/
async toNoteDto(note: Note): Promise<NoteDto> {
return {
content: await this.getNoteContent(note),
metadata: await this.toNoteMetadataDto(note),
editedByAtPosition: [],
};
}
}

View file

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

View file

@ -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<Note>,
@InjectRepository(Tag) private tagRepository: Repository<Tag>,
@InjectRepository(Alias) private aliasRepository: Repository<Alias>,
@InjectRepository(User) private userRepository: Repository<User>,
@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<NoteEventMap>,
) {
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<Note[]> {
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<Note> {
// 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<string> {
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<Note> {
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<User[]> {
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<void> {
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<boolean> {
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<Note> {
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<Note> {
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<User | null> {
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<NotePermissionsDto> {
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<NoteMetadataDto> {
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<NoteDto> {
return {
content: await this.getNoteContent(note),
metadata: await this.toNoteMetadataDto(note),
editedByAtPosition: [],
};
}
}

View file

@ -1,14 +1,14 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { User } from '../database/user.entity'; import { User } from '../database/user.entity';
import { Alias } from './alias.entity'; import { Alias } from './aliases.entity';
import { Note } from './note.entity'; import { Note } from './note.entity';
import { generatePublicId, getPrimaryAlias } from './utils'; import { generateRandomAlias, getPrimaryAlias } from './utils';
jest.mock('crypto'); jest.mock('crypto');
const random128bitBuffer = Buffer.from([ const random128bitBuffer = Buffer.from([
@ -19,7 +19,7 @@ const mockRandomBytes = randomBytes as jest.MockedFunction<typeof randomBytes>;
mockRandomBytes.mockImplementation((_) => random128bitBuffer); mockRandomBytes.mockImplementation((_) => random128bitBuffer);
it('generatePublicId', () => { it('generatePublicId', () => {
expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc'); expect(generateRandomAlias()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc');
}); });
describe('getPrimaryAlias', () => { describe('getPrimaryAlias', () => {
@ -29,11 +29,11 @@ describe('getPrimaryAlias', () => {
const user = User.create('hardcoded', 'Testy') as User; const user = User.create('hardcoded', 'Testy') as User;
note = Note.create(user, alias) as Note; note = Note.create(user, alias) as Note;
}); });
it('finds correct primary alias', async () => { it('finds correct primary aliases', async () => {
(await note.aliases).push(Alias.create('annother', note, false) as Alias); (await note.aliases).push(Alias.create('annother', note, false) as Alias);
expect(await getPrimaryAlias(note)).toEqual(alias); expect(await getPrimaryAlias(note)).toEqual(alias);
}); });
it('returns undefined if there is no alias', async () => { it('returns undefined if there is no aliases', async () => {
(await note.aliases)[0].primary = false; (await note.aliases)[0].primary = false;
expect(await getPrimaryAlias(note)).toEqual(undefined); expect(await getPrimaryAlias(note)).toEqual(undefined);
}); });

View file

@ -1,26 +1,17 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import base32Encode from 'base32-encode'; import base32Encode from 'base32-encode';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { Alias } from './alias.entity'; import { Alias } from './aliases.entity';
import { Note } from './note.entity'; import { Note } from './note.entity';
/** /**
* Generate publicId for a note. * Extract the primary aliases from a aliases of a note
* This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase. * @param {Note} note - the note from which the primary aliases should be extracted
*/
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<string | undefined> { export async function getPrimaryAlias(note: Note): Promise<string | undefined> {
const listWithPrimaryAlias = (await note.aliases).filter( const listWithPrimaryAlias = (await note.aliases).filter(

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { import {
getNotePermissionDisplayName, getNotePermissionLevelDisplayName,
NotePermission, NotePermissionLevel,
} from './note-permission.enum'; } from './note-permission.enum';
describe('note permission order', () => { describe('note permission order', () => {
it('DENY is less than READ', () => { it('DENY is less than READ', () => {
expect(NotePermission.DENY < NotePermission.READ).toBeTruthy(); expect(NotePermissionLevel.DENY < NotePermissionLevel.READ).toBeTruthy();
}); });
it('READ is less than WRITE', () => { it('READ is less than WRITE', () => {
expect(NotePermission.READ < NotePermission.WRITE).toBeTruthy(); expect(NotePermissionLevel.READ < NotePermissionLevel.WRITE).toBeTruthy();
}); });
it('WRITE is less than OWNER', () => { it('WRITE is less than OWNER', () => {
expect(NotePermission.WRITE < NotePermission.OWNER).toBeTruthy(); expect(NotePermissionLevel.WRITE < NotePermissionLevel.OWNER).toBeTruthy();
}); });
}); });
describe('getNotePermissionDisplayName', () => { describe('getNotePermissionDisplayName', () => {
it.each([ it.each([
['deny', NotePermission.DENY], ['deny', NotePermissionLevel.DENY],
['read', NotePermission.READ], ['read', NotePermissionLevel.READ],
['write', NotePermission.WRITE], ['write', NotePermissionLevel.WRITE],
['owner', NotePermission.OWNER], ['owner', NotePermissionLevel.OWNER],
])('displays %s correctly', (displayName, permission) => { ])('displays %s correctly', (displayName, permission) => {
expect(getNotePermissionDisplayName(permission)).toBe(displayName); expect(getNotePermissionLevelDisplayName(permission)).toBe(displayName);
}); });
}); });

View file

@ -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 * 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. * Defines if a user can access a note and if yes how much power they have.
*/ */
export enum NotePermission { export enum NotePermissionLevel {
DENY = 0, DENY = 0,
READ = 1, READ = 1,
WRITE = 2, 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 * @return {string} The display name
*/ */
export function getNotePermissionDisplayName(value: NotePermission): string { export function getNotePermissionLevelDisplayName(
value: NotePermissionLevel,
): string {
switch (value) { switch (value) {
case NotePermission.DENY: case NotePermissionLevel.DENY:
return 'deny'; return 'deny';
case NotePermission.READ: case NotePermissionLevel.READ:
return 'read'; return 'read';
case NotePermission.WRITE: case NotePermissionLevel.WRITE:
return 'write'; return 'write';
case NotePermission.OWNER: case NotePermissionLevel.OWNER:
return 'owner'; return 'owner';
} }
} }

View file

@ -0,0 +1,474 @@
/*
* SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import {
NotePermissionsDto,
PermissionLevel,
SpecialGroup,
} from '@hedgedoc/commons';
import { Inject, Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import {
FieldNameGroup,
FieldNameGroupUser,
FieldNameMediaUpload,
FieldNameNote,
FieldNameNoteGroupPermission,
FieldNameNoteUserPermission,
FieldNameUser,
Note,
TableGroup,
TableGroupUser,
TableMediaUpload,
TableNote,
TableNoteGroupPermission,
TableNoteUserPermission,
TableUser,
User,
} from '../database/types';
import { GenericDBError, NotInDBError } from '../errors/errors';
import { NoteEvent, NoteEventMap } from '../events';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { NotePermissionLevel } from './note-permission.enum';
import { convertEditabilityToPermissionLevel } from './utils/convert-editability-to-note-permission-level';
import { convertPermissionLevelToNotePermissionLevel } from './utils/convert-guest-access-to-note-permission-level';
@Injectable()
export class PermissionService {
constructor(
@InjectConnection()
private readonly knex: Knex,
private readonly logger: ConsoleLoggerService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
private eventEmitter: EventEmitter2<NoteEventMap>,
) {}
/**
* 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<boolean> {
const mediaUploadAndNote = await this.knex(TableMediaUpload)
.join(
TableNote,
`${TableMediaUpload}.${FieldNameMediaUpload.noteId}`,
'=',
`${TableNote}.${FieldNameNote.id}`,
)
.select(FieldNameMediaUpload.userId, FieldNameNote.ownerId)
.where(FieldNameMediaUpload.uuid, mediaUploadUuid)
.first();
if (!mediaUploadAndNote) {
throw new NotInDBError(
`There is no upload with the id ${mediaUploadUuid}`,
this.logger.getContext(),
'checkMediaDeletePermission',
);
}
return (
mediaUploadAndNote[FieldNameMediaUpload.userId] === userId ||
mediaUploadAndNote[FieldNameNote.ownerId] === userId
);
}
/**
* Checks if the given {@link User} is allowed to create notes.
*
* @param userId - The user whose permission should be checked. Value is null if guest access should be checked
* @return if the user is allowed to create notes
*/
public mayCreate(userId: number | null): boolean {
return (
userId !== null || this.noteConfig.guestAccess === PermissionLevel.CREATE
);
}
/**
* Checks if the given {@link User} is the owner of a note
*
* @param userId The id of the user
* @param noteId The id of the note
* @param transaction Optional transaction to use
* @return true if the user is the owner of the note
*/
async isOwner(
userId: number | null,
noteId: number,
transaction?: Knex,
): Promise<boolean> {
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<NotePermissionLevel>} The determined permission
*/
public async determinePermission(
userId: number | null,
noteId: number,
): Promise<NotePermissionLevel> {
if (userId === null) {
return await this.determineNotePermissionLevelForGuest(noteId);
}
return await this.knex.transaction(async (transaction) => {
if (await this.isOwner(userId, noteId, transaction)) {
return NotePermissionLevel.OWNER;
}
const userPermission = await this.determineNotePermissionLevelForUser(
userId,
noteId,
transaction,
);
if (userPermission === NotePermissionLevel.WRITE) {
return userPermission;
}
const groupPermission =
await this.determineHighestNotePermissionLevelOfGroups(
userId,
noteId,
transaction,
);
return groupPermission > userPermission
? groupPermission
: userPermission;
});
}
/**
* Determines the access level for a given user to a given note
*
* @param userId The id of the user who wants access
* @param noteId The id of the note for which access is checked
* @param transaction The optional database transaction to use
* @private
*/
private async determineNotePermissionLevelForUser(
userId: number,
noteId: number,
transaction?: Knex,
): Promise<NotePermissionLevel> {
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<NotePermissionLevel> {
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<NotePermissionLevel> {
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<void> {
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<void> {
const result = await this.knex(TableNoteUserPermission)
.where(FieldNameNoteUserPermission.noteId, noteId)
.andWhere(FieldNameNoteUserPermission.userId, userId)
.delete();
if (result !== 0) {
throw new NotInDBError(
`The user does not have a permission on this note.`,
this.logger.getContext(),
'removeUserPermission',
);
}
this.notifyOthers(noteId);
}
/**
* Set permission for a specific group on a note.
* @param noteId - the if of the note
* @param groupId - the name of the group for which the permission should be set
* @param canEdit - specifies if the group can edit the note
*/
async setGroupPermission(
noteId: number,
groupId: number,
canEdit: boolean,
): Promise<void> {
await this.knex(TableNoteGroupPermission)
.insert({
[FieldNameNoteGroupPermission.groupId]: groupId,
[FieldNameNoteGroupPermission.noteId]: noteId,
[FieldNameNoteGroupPermission.canEdit]: canEdit,
})
.onConflict([
FieldNameNoteGroupPermission.noteId,
FieldNameNoteGroupPermission.groupId,
])
.merge();
this.notifyOthers(noteId);
}
/**
* Remove permission for a specific group on a note.
* @param noteId - the note
* @param groupId - the group for which the permission should be set
* @return the note with the new permission
*/
async removeGroupPermission(noteId: number, groupId: number): Promise<void> {
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<void> {
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<NotePermissionsDto> {
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],
})),
};
});
}
}

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -7,33 +7,33 @@ import { ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Mock } from 'ts-mockery'; 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 { CompleteRequest } from '../api/utils/request.type';
import { User } from '../database/user.entity'; import { User } from '../database/user.entity';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { Note } from '../notes/note.entity'; import { Note } from '../notes/note.entity';
import { import {
getNotePermissionDisplayName, getNotePermissionLevelDisplayName,
NotePermission, NotePermissionLevel,
} from './note-permission.enum'; } from './note-permission.enum';
import { PermissionService } from './permission.service';
import { PermissionsGuard } from './permissions.guard'; import { PermissionsGuard } from './permissions.guard';
import { PermissionsService } from './permissions.service';
import { PERMISSION_METADATA_KEY } from './require-permission.decorator'; import { PERMISSION_METADATA_KEY } from './require-permission.decorator';
import { RequiredPermission } from './required-permission.enum'; 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', () => { describe('permissions guard', () => {
let loggerService: ConsoleLoggerService; let loggerService: ConsoleLoggerService;
let reflector: Reflector; let reflector: Reflector;
let handler: () => void; let handler: () => void;
let permissionsService: PermissionsService; let permissionsService: PermissionService;
let requiredPermission: RequiredPermission | undefined; let requiredPermission: RequiredPermission | undefined;
let createAllowed = false; let createAllowed = false;
let requestUser: User | undefined; let requestUser: User | undefined;
let context: ExecutionContext; let context: ExecutionContext;
let permissionGuard: PermissionsGuard; let permissionGuard: PermissionsGuard;
let determinedPermission: NotePermission; let determinedPermission: NotePermissionLevel;
let mockedNote: Note; let mockedNote: Note;
beforeEach(() => { beforeEach(() => {
@ -48,7 +48,7 @@ describe('permissions guard', () => {
handler = jest.fn(); handler = jest.fn();
permissionsService = Mock.of<PermissionsService>({ permissionsService = Mock.of<PermissionService>({
mayCreate: jest.fn(() => createAllowed), mayCreate: jest.fn(() => createAllowed),
determinePermission: jest.fn(() => Promise.resolve(determinedPermission)), determinePermission: jest.fn(() => Promise.resolve(determinedPermission)),
}); });
@ -68,7 +68,7 @@ describe('permissions guard', () => {
}); });
mockedNote = Mock.of<Note>({}); mockedNote = Mock.of<Note>({});
jest jest
.spyOn(ExtractNoteIdOrAliasModule, 'extractNoteFromRequest') .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest')
.mockReturnValue(Promise.resolve(mockedNote)); .mockReturnValue(Promise.resolve(mockedNote));
permissionGuard = new PermissionsGuard( 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 jest
.spyOn(ExtractNoteIdOrAliasModule, 'extractNoteFromRequest') .spyOn(ExtractNoteIdOrAliasModule, 'extractNoteIdFromRequest')
.mockReturnValue(Promise.resolve(undefined)); .mockReturnValue(Promise.resolve(undefined));
requiredPermission = RequiredPermission.READ; requiredPermission = RequiredPermission.READ;
@ -151,9 +151,21 @@ describe('permissions guard', () => {
}); });
describe.each([ describe.each([
[RequiredPermission.READ, NotePermission.READ, NotePermission.DENY], [
[RequiredPermission.WRITE, NotePermission.WRITE, NotePermission.READ], RequiredPermission.READ,
[RequiredPermission.OWNER, NotePermission.OWNER, NotePermission.WRITE], NotePermissionLevel.READ,
NotePermissionLevel.DENY,
],
[
RequiredPermission.WRITE,
NotePermissionLevel.WRITE,
NotePermissionLevel.READ,
],
[
RequiredPermission.OWNER,
NotePermissionLevel.OWNER,
NotePermissionLevel.WRITE,
],
])( ])(
'with required permission %s', 'with required permission %s',
( (
@ -161,12 +173,10 @@ describe('permissions guard', () => {
sufficientNotePermission, sufficientNotePermission,
notEnoughNotePermission, notEnoughNotePermission,
) => { ) => {
const sufficientNotePermissionDisplayName = getNotePermissionDisplayName( const sufficientNotePermissionDisplayName =
sufficientNotePermission, getNotePermissionLevelDisplayName(sufficientNotePermission);
); const notEnoughNotePermissionDisplayName =
const notEnoughNotePermissionDisplayName = getNotePermissionDisplayName( getNotePermissionLevelDisplayName(notEnoughNotePermission);
notEnoughNotePermission,
);
beforeEach(() => { beforeEach(() => {
requiredPermission = shouldRequiredPermission; requiredPermission = shouldRequiredPermission;

View file

@ -1,17 +1,17 @@
/* /*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; 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 { CompleteRequest } from '../api/utils/request.type';
import { ConsoleLoggerService } from '../logger/console-logger.service'; import { ConsoleLoggerService } from '../logger/console-logger.service';
import { NotesService } from '../notes/notes.service'; import { NoteService } from '../notes/note.service';
import { NotePermission } from './note-permission.enum'; import { NotePermissionLevel } from './note-permission.enum';
import { PermissionsService } from './permissions.service'; import { PermissionService } from './permission.service';
import { PERMISSION_METADATA_KEY } from './require-permission.decorator'; import { PERMISSION_METADATA_KEY } from './require-permission.decorator';
import { RequiredPermission } from './required-permission.enum'; import { RequiredPermission } from './required-permission.enum';
@ -26,8 +26,8 @@ export class PermissionsGuard implements CanActivate {
constructor( constructor(
private readonly logger: ConsoleLoggerService, private readonly logger: ConsoleLoggerService,
private readonly reflector: Reflector, private readonly reflector: Reflector,
private readonly permissionsService: PermissionsService, private readonly permissionsService: PermissionService,
private readonly noteService: NotesService, private readonly noteService: NoteService,
) { ) {
this.logger.setContext(PermissionsGuard.name); this.logger.setContext(PermissionsGuard.name);
} }
@ -38,15 +38,15 @@ export class PermissionsGuard implements CanActivate {
return false; return false;
} }
const request: CompleteRequest = context.switchToHttp().getRequest(); const request: CompleteRequest = context.switchToHttp().getRequest();
const user = request.user ?? null; const userId = request.userId ?? null;
// handle CREATE requiredAccessLevel, as this does not need any note // handle CREATE requiredAccessLevel, as this does not need any note
if (requiredAccessLevel === RequiredPermission.CREATE) { if (requiredAccessLevel === RequiredPermission.CREATE) {
return this.permissionsService.mayCreate(user); return this.permissionsService.mayCreate(userId);
} }
const note = await extractNoteFromRequest(request, this.noteService); const noteId = await extractNoteIdFromRequest(request, this.noteService);
if (note === undefined) { if (noteId === undefined) {
this.logger.error( 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', 'Could not find noteIdOrAlias metadata. This should never happen. If you see this, please open an issue at https://github.com/hedgedoc/hedgedoc/issues',
); );
@ -55,7 +55,7 @@ export class PermissionsGuard implements CanActivate {
return this.isNotePermissionFulfillingRequiredAccessLevel( return this.isNotePermissionFulfillingRequiredAccessLevel(
requiredAccessLevel, requiredAccessLevel,
await this.permissionsService.determinePermission(user, note), await this.permissionsService.determinePermission(userId, noteId),
); );
} }
@ -78,15 +78,15 @@ export class PermissionsGuard implements CanActivate {
private isNotePermissionFulfillingRequiredAccessLevel( private isNotePermissionFulfillingRequiredAccessLevel(
requiredAccessLevel: Exclude<RequiredPermission, RequiredPermission.CREATE>, requiredAccessLevel: Exclude<RequiredPermission, RequiredPermission.CREATE>,
actualNotePermission: NotePermission, actualNotePermission: NotePermissionLevel,
): boolean { ): boolean {
switch (requiredAccessLevel) { switch (requiredAccessLevel) {
case RequiredPermission.READ: case RequiredPermission.READ:
return actualNotePermission >= NotePermission.READ; return actualNotePermission >= NotePermissionLevel.READ;
case RequiredPermission.WRITE: case RequiredPermission.WRITE:
return actualNotePermission >= NotePermission.WRITE; return actualNotePermission >= NotePermissionLevel.WRITE;
case RequiredPermission.OWNER: case RequiredPermission.OWNER:
return actualNotePermission >= NotePermission.OWNER; return actualNotePermission >= NotePermissionLevel.OWNER;
} }
} }
} }

View file

@ -1,25 +1,19 @@
/* /*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2025 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { KnexModule } from 'nest-knexjs';
import { GroupsModule } from '../groups/groups.module'; import { GroupsModule } from '../groups/groups.module';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { PermissionsService } from './permissions.service'; import { PermissionService } from './permission.service';
@Module({ @Module({
imports: [ imports: [KnexModule, LoggerModule],
TypeOrmModule.forFeature([Note]), exports: [PermissionService],
UsersModule, providers: [PermissionService],
GroupsModule,
LoggerModule,
],
exports: [PermissionsService],
providers: [PermissionsService],
}) })
export class PermissionsModule {} export class PermissionsModule {}

View file

@ -4,9 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { import {
GuestAccess,
NoteGroupPermissionUpdateDto, NoteGroupPermissionUpdateDto,
NoteUserPermissionUpdateDto, NoteUserPermissionUpdateDto,
PermissionLevel,
} from '@hedgedoc/commons'; } from '@hedgedoc/commons';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter'; import { EventEmitter2, EventEmitterModule } from '@nestjs/event-emitter';
@ -15,6 +15,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { DataSource, EntityManager, Repository } from 'typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm';
import { AliasModule } from '../alias/alias.module';
import { ApiToken } from '../api-token/api-token.entity'; import { ApiToken } from '../api-token/api-token.entity';
import { Identity } from '../auth/identity.entity'; import { Identity } from '../auth/identity.entity';
import { Author } from '../authors/author.entity'; import { Author } from '../authors/author.entity';
@ -35,9 +36,8 @@ import { GroupsModule } from '../groups/groups.module';
import { GroupsService } from '../groups/groups.service'; import { GroupsService } from '../groups/groups.service';
import { LoggerModule } from '../logger/logger.module'; import { LoggerModule } from '../logger/logger.module';
import { MediaUpload } from '../media/media-upload.entity'; 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 { Note } from '../notes/note.entity';
import { NotesModule } from '../notes/notes.module';
import { Tag } from '../notes/tag.entity'; import { Tag } from '../notes/tag.entity';
import { Edit } from '../revisions/edit.entity'; import { Edit } from '../revisions/edit.entity';
import { Revision } from '../revisions/revision.entity'; import { Revision } from '../revisions/revision.entity';
@ -45,13 +45,13 @@ import { Session } from '../sessions/session.entity';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
import { NoteGroupPermission } from './note-group-permission.entity'; import { NoteGroupPermission } from './note-group-permission.entity';
import { import {
getNotePermissionDisplayName, getNotePermissionLevelDisplayName,
NotePermission, NotePermissionLevel,
} from './note-permission.enum'; } from './note-permission.enum';
import { NoteUserPermission } from './note-user-permission.entity'; import { NoteUserPermission } from './note-user-permission.entity';
import { PermissionService } from './permission.service';
import { PermissionsModule } from './permissions.module'; import { PermissionsModule } from './permissions.module';
import { PermissionsService } from './permissions.service'; import { convertPermissionLevelToNotePermissionLevel } from './utils/convert-guest-access-to-note-permission-level';
import { convertGuestAccessToNotePermission } from './utils/convert-guest-access-to-note-permission';
import * as FindHighestNotePermissionByGroupModule from './utils/find-highest-note-permission-by-group'; import * as FindHighestNotePermissionByGroupModule from './utils/find-highest-note-permission-by-group';
import * as FindHighestNotePermissionByUserModule from './utils/find-highest-note-permission-by-user'; import * as FindHighestNotePermissionByUserModule from './utils/find-highest-note-permission-by-user';
@ -89,7 +89,7 @@ function mockNoteRepo(noteRepo: Repository<Note>) {
} }
describe('PermissionsService', () => { describe('PermissionsService', () => {
let service: PermissionsService; let service: PermissionService;
let groupService: GroupsService; let groupService: GroupsService;
let noteRepo: Repository<Note>; let noteRepo: Repository<Note>;
let userRepo: Repository<User>; let userRepo: Repository<User>;
@ -128,7 +128,7 @@ describe('PermissionsService', () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [
PermissionsService, PermissionService,
{ {
provide: getRepositoryToken(Note), provide: getRepositoryToken(Note),
useValue: noteRepo, useValue: noteRepo,
@ -146,7 +146,7 @@ describe('PermissionsService', () => {
LoggerModule, LoggerModule,
PermissionsModule, PermissionsModule,
UsersModule, UsersModule,
NotesModule, AliasModule,
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [ load: [
@ -187,7 +187,7 @@ describe('PermissionsService', () => {
.overrideProvider(getRepositoryToken(Alias)) .overrideProvider(getRepositoryToken(Alias))
.useValue({}) .useValue({})
.compile(); .compile();
service = module.get<PermissionsService>(PermissionsService); service = module.get<PermissionService>(PermissionService);
groupService = module.get<GroupsService>(GroupsService); groupService = module.get<GroupsService>(GroupsService);
groupRepo = module.get<Repository<Group>>(getRepositoryToken(Group)); groupRepo = module.get<Repository<Group>>(getRepositoryToken(Group));
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note)); noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
@ -229,13 +229,13 @@ describe('PermissionsService', () => {
expect(service.mayCreate(user1)).toBeTruthy(); expect(service.mayCreate(user1)).toBeTruthy();
}); });
it('allows creation of notes for guests with permission', () => { 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.loggedIn = DefaultAccessLevel.WRITE;
noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE;
expect(service.mayCreate(null)).toBeTruthy(); expect(service.mayCreate(null)).toBeTruthy();
}); });
it('denies creation of notes for guests without permission', () => { 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.loggedIn = DefaultAccessLevel.WRITE;
noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE; noteMockConfig.permissions.default.everyone = DefaultAccessLevel.WRITE;
expect(service.mayCreate(null)).toBeFalsy(); expect(service.mayCreate(null)).toBeFalsy();
@ -318,34 +318,34 @@ describe('PermissionsService', () => {
it(`with no everyone permission will deny`, async () => { it(`with no everyone permission will deny`, async () => {
const note = mockNote(user1, [], [loggedInReadPermission]); const note = mockNote(user1, [], [loggedInReadPermission]);
const foundPermission = await service.determinePermission(null, note); const foundPermission = await service.determinePermission(null, note);
expect(foundPermission).toBe(NotePermission.DENY); expect(foundPermission).toBe(NotePermissionLevel.DENY);
}); });
describe.each([ describe.each([
GuestAccess.DENY, PermissionLevel.DENY,
GuestAccess.READ, PermissionLevel.READ,
GuestAccess.WRITE, PermissionLevel.WRITE,
GuestAccess.CREATE, PermissionLevel.CREATE,
])('with configured guest access %s', (guestAccess) => { ])('with configured guest access %s', (guestAccess) => {
beforeEach(() => { beforeEach(() => {
noteMockConfig.guestAccess = guestAccess; noteMockConfig.guestAccess = guestAccess;
}); });
const guestAccessNotePermission = const guestAccessNotePermission =
convertGuestAccessToNotePermission(guestAccess); convertPermissionLevelToNotePermissionLevel(guestAccess);
describe.each([false, true])( describe.each([false, true])(
'with everybody group permission with edit set to %s', 'with everybody group permission with edit set to %s',
(canEdit) => { (canEdit) => {
const editPermission = canEdit const editPermission = canEdit
? NotePermission.WRITE ? NotePermissionLevel.WRITE
: NotePermission.READ; : NotePermissionLevel.READ;
const expectedLimitedPermission = const expectedLimitedPermission =
guestAccessNotePermission >= editPermission guestAccessNotePermission >= editPermission
? editPermission ? editPermission
: guestAccessNotePermission; : guestAccessNotePermission;
const permissionDisplayName = getNotePermissionDisplayName( const permissionDisplayName = getNotePermissionLevelDisplayName(
expectedLimitedPermission, expectedLimitedPermission,
); );
it(`will ${permissionDisplayName}`, async () => { it(`will ${permissionDisplayName}`, async () => {
@ -381,7 +381,7 @@ describe('PermissionsService', () => {
note, note,
); );
expect(foundPermission).toBe(NotePermission.OWNER); expect(foundPermission).toBe(NotePermissionLevel.OWNER);
}); });
it('with other lower permissions', async () => { it('with other lower permissions', async () => {
const userPermission = Mock.of<NoteUserPermission>({ const userPermission = Mock.of<NoteUserPermission>({
@ -407,7 +407,7 @@ describe('PermissionsService', () => {
note, note,
); );
expect(foundPermission).toBe(NotePermission.OWNER); expect(foundPermission).toBe(NotePermissionLevel.OWNER);
}); });
}); });
describe('as non owner', () => { describe('as non owner', () => {
@ -417,13 +417,13 @@ describe('PermissionsService', () => {
FindHighestNotePermissionByUserModule, FindHighestNotePermissionByUserModule,
'findHighestNotePermissionByUser', 'findHighestNotePermissionByUser',
) )
.mockReturnValue(Promise.resolve(NotePermission.DENY)); .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY));
jest jest
.spyOn( .spyOn(
FindHighestNotePermissionByGroupModule, FindHighestNotePermissionByGroupModule,
'findHighestNotePermissionByGroup', 'findHighestNotePermissionByGroup',
) )
.mockReturnValue(Promise.resolve(NotePermission.WRITE)); .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE));
const note = mockNote(user2); const note = mockNote(user2);
@ -431,7 +431,7 @@ describe('PermissionsService', () => {
user1, user1,
note, note,
); );
expect(foundPermission).toBe(NotePermission.WRITE); expect(foundPermission).toBe(NotePermissionLevel.WRITE);
}); });
it('with group permission higher than user permission', async () => { it('with group permission higher than user permission', async () => {
@ -440,13 +440,13 @@ describe('PermissionsService', () => {
FindHighestNotePermissionByUserModule, FindHighestNotePermissionByUserModule,
'findHighestNotePermissionByUser', 'findHighestNotePermissionByUser',
) )
.mockReturnValue(Promise.resolve(NotePermission.WRITE)); .mockReturnValue(Promise.resolve(NotePermissionLevel.WRITE));
jest jest
.spyOn( .spyOn(
FindHighestNotePermissionByGroupModule, FindHighestNotePermissionByGroupModule,
'findHighestNotePermissionByGroup', 'findHighestNotePermissionByGroup',
) )
.mockReturnValue(Promise.resolve(NotePermission.DENY)); .mockReturnValue(Promise.resolve(NotePermissionLevel.DENY));
const note = mockNote(user2); const note = mockNote(user2);
@ -454,7 +454,7 @@ describe('PermissionsService', () => {
user1, user1,
note, note,
); );
expect(foundPermission).toBe(NotePermission.WRITE); expect(foundPermission).toBe(NotePermissionLevel.WRITE);
}); });
}); });
}); });
@ -479,7 +479,7 @@ describe('PermissionsService', () => {
const note = Note.create(user) as Note; const note = Note.create(user) as Note;
it('emits PERMISSION_CHANGE event', async () => { it('emits PERMISSION_CHANGE event', async () => {
expect(eventEmitterEmitSpy).not.toHaveBeenCalled(); expect(eventEmitterEmitSpy).not.toHaveBeenCalled();
await service.updateNotePermissions(note, { await service.replaceNotePermissions(note, {
sharedToUsers: [], sharedToUsers: [],
sharedToGroups: [], sharedToGroups: [],
}); });
@ -487,7 +487,7 @@ describe('PermissionsService', () => {
}); });
describe('works', () => { describe('works', () => {
it('with empty GroupPermissions and with empty UserPermissions', async () => { it('with empty GroupPermissions and with empty UserPermissions', async () => {
const savedNote = await service.updateNotePermissions(note, { const savedNote = await service.replaceNotePermissions(note, {
sharedToUsers: [], sharedToUsers: [],
sharedToGroups: [], sharedToGroups: [],
}); });
@ -496,7 +496,7 @@ describe('PermissionsService', () => {
}); });
it('with empty GroupPermissions and with new UserPermissions', async () => { it('with empty GroupPermissions and with new UserPermissions', async () => {
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
const savedNote = await service.updateNotePermissions(note, { const savedNote = await service.replaceNotePermissions(note, {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
sharedToGroups: [], sharedToGroups: [],
}); });
@ -521,7 +521,7 @@ describe('PermissionsService', () => {
]); ]);
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
const savedNote = await service.updateNotePermissions(note, { const savedNote = await service.replaceNotePermissions(note, {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
sharedToGroups: [], sharedToGroups: [],
}); });
@ -536,7 +536,7 @@ describe('PermissionsService', () => {
}); });
it('with new GroupPermissions and with empty UserPermissions', async () => { it('with new GroupPermissions and with empty UserPermissions', async () => {
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions(note, { const savedNote = await service.replaceNotePermissions(note, {
sharedToUsers: [], sharedToUsers: [],
sharedToGroups: [groupPermissionUpdate], sharedToGroups: [groupPermissionUpdate],
}); });
@ -551,7 +551,7 @@ describe('PermissionsService', () => {
it('with new GroupPermissions and with new UserPermissions', async () => { it('with new GroupPermissions and with new UserPermissions', async () => {
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions(note, { const savedNote = await service.replaceNotePermissions(note, {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
sharedToGroups: [groupPermissionUpdate], sharedToGroups: [groupPermissionUpdate],
}); });
@ -581,7 +581,7 @@ describe('PermissionsService', () => {
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions( const savedNote = await service.replaceNotePermissions(
noteWithUserPermission, noteWithUserPermission,
{ {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
@ -612,7 +612,7 @@ describe('PermissionsService', () => {
}, },
]); ]);
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions( const savedNote = await service.replaceNotePermissions(
noteWithPreexistingPermissions, noteWithPreexistingPermissions,
{ {
sharedToUsers: [], sharedToUsers: [],
@ -640,7 +640,7 @@ describe('PermissionsService', () => {
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions( const savedNote = await service.replaceNotePermissions(
noteWithPreexistingPermissions, noteWithPreexistingPermissions,
{ {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
@ -681,7 +681,7 @@ describe('PermissionsService', () => {
jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user); jest.spyOn(userRepo, 'findOne').mockResolvedValueOnce(user);
jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group); jest.spyOn(groupRepo, 'findOne').mockResolvedValueOnce(group);
const savedNote = await service.updateNotePermissions( const savedNote = await service.replaceNotePermissions(
noteWithPreexistingPermissions, noteWithPreexistingPermissions,
{ {
sharedToUsers: [userPermissionUpdate], sharedToUsers: [userPermissionUpdate],
@ -705,7 +705,7 @@ describe('PermissionsService', () => {
describe('fails:', () => { describe('fails:', () => {
it('userPermissions has duplicate entries', async () => { it('userPermissions has duplicate entries', async () => {
await expect( await expect(
service.updateNotePermissions(note, { service.replaceNotePermissions(note, {
sharedToUsers: [userPermissionUpdate, userPermissionUpdate], sharedToUsers: [userPermissionUpdate, userPermissionUpdate],
sharedToGroups: [], sharedToGroups: [],
}), }),
@ -714,7 +714,7 @@ describe('PermissionsService', () => {
it('groupPermissions has duplicate entries', async () => { it('groupPermissions has duplicate entries', async () => {
await expect( await expect(
service.updateNotePermissions(note, { service.replaceNotePermissions(note, {
sharedToUsers: [], sharedToUsers: [],
sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate],
}), }),
@ -723,7 +723,7 @@ describe('PermissionsService', () => {
it('userPermissions and groupPermissions have duplicate entries', async () => { it('userPermissions and groupPermissions have duplicate entries', async () => {
await expect( await expect(
service.updateNotePermissions(note, { service.replaceNotePermissions(note, {
sharedToUsers: [userPermissionUpdate, userPermissionUpdate], sharedToUsers: [userPermissionUpdate, userPermissionUpdate],
sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate], sharedToGroups: [groupPermissionUpdate, groupPermissionUpdate],
}), }),

View file

@ -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<Note>,
private readonly logger: ConsoleLoggerService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
private eventEmitter: EventEmitter2<NoteEventMap>,
) {}
public async checkMediaDeletePermission(
user: User,
mediaUpload: MediaUpload,
): Promise<boolean> {
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<boolean> {
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<NotePermission>} The determined permission
*/
public async determinePermission(
user: User | null,
note: Note,
): Promise<NotePermission> {
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<NotePermission> {
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<Note> {
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<Note> {
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<NoteUserPermission | undefined> {
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<Note> {
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<Note> {
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<NoteGroupPermission | undefined> {
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<Note> {
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> {
note.owner = Promise.resolve(owner);
this.notifyOthers(note);
return await this.noteRepository.save(note);
}
}

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more