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",
"file-type": "16.5.4",
"htmlparser2": "9.1.0",
"keyv": "^5.3.2",
"knex": "3.1.0",
"ldapauth-fork": "6.1.0",
"markdown-it": "13.0.2",
"minio": "8.0.4",
"mysql": "2.18.1",
"nestjs-knex": "2.0.0",
"nest-knexjs": "0.0.26",
"nestjs-zod": "4.3.1",
"node-fetch": "2.7.0",
"openid-client": "5.7.1",

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

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
*/
import { AuthProviderType } from '@hedgedoc/commons';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { CompleteRequest } from '../api/utils/request.type';
@ -30,7 +31,10 @@ export class ApiTokenGuard implements CanActivate {
return false;
}
try {
request.user = await this.apiTokenService.validateToken(token.trim());
request.userId = await this.apiTokenService.getUserIdForToken(
token.trim(),
);
request.authProviderType = AuthProviderType.TOKEN;
return true;
} catch (error) {
if (

View file

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

View file

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

View file

@ -6,19 +6,29 @@
import { ApiTokenDto, ApiTokenWithSecretDto } from '@hedgedoc/commons';
import { Injectable } from '@nestjs/common';
import { Cron, Timeout } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
import { Repository } from 'typeorm';
import { randomBytes } from 'crypto';
import { Knex } from 'knex';
import { InjectConnection } from 'nest-knexjs';
import { User } from '../database/user.entity';
import {
ApiToken,
FieldNameApiToken,
FieldNameUser,
TableApiToken, TableUser,
User,
} from '../database/types';
import { TypeInsertApiToken } from '../database/types/api-token';
import {
NotInDBError,
TokenNotValidError,
TooManyTokensError,
} from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { bufferToBase64Url, checkTokenEquality } from '../utils/password';
import { ApiToken } from './api-token.entity';
import {
bufferToBase64Url,
checkTokenEquality,
hashApiToken,
} from '../utils/password';
export const AUTH_TOKEN_PREFIX = 'hd2';
@ -26,13 +36,22 @@ export const AUTH_TOKEN_PREFIX = 'hd2';
export class ApiTokenService {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(ApiToken)
private authTokenRepository: Repository<ApiToken>,
@InjectConnection()
private readonly knex: Knex,
) {
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('.');
if (!keyId || !secret || prefix !== AUTH_TOKEN_PREFIX || rest.length > 0) {
throw new TokenNotValidError('Invalid API token format');
@ -44,179 +63,202 @@ export class ApiTokenService {
`API token '${tokenString}' has incorrect length`,
);
}
const token = await this.getToken(keyId);
this.checkToken(secret, token);
await this.setLastUsedToken(keyId);
return token.user;
return await this.knex.transaction(async (transaction) => {
const token = await transaction(TableApiToken)
.select(
FieldNameApiToken.secretHash,
FieldNameApiToken.userId,
FieldNameApiToken.validUntil,
)
.where(FieldNameApiToken.id, keyId)
.first();
if (token === undefined) {
throw new TokenNotValidError('Token not found');
}
const tokenHash = token[FieldNameApiToken.secretHash];
const validUntil = token[FieldNameApiToken.validUntil];
this.ensureTokenIsValid(secret, tokenHash, validUntil);
await transaction(TableApiToken)
.update(FieldNameApiToken.lastUsedAt, this.knex.fn.now())
.where(FieldNameApiToken.id, keyId);
return token[FieldNameApiToken.userId];
});
}
createToken(
user: User,
identifier: string,
userDefinedValidUntil: Date | null,
): [Omit<ApiToken, 'id' | 'createdAt'>, string] {
const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8));
// More about the choice of SHA-512 in the dev docs
const accessTokenHash = createHash('sha512').update(secret).digest('hex');
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date();
maximumTokenValidity.setTime(
maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000,
);
const isTokenLimitedToMaximumValidity =
!userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity;
const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity
: userDefinedValidUntil;
const token = ApiToken.create(
keyId,
user,
identifier,
accessTokenHash,
new Date(validUntil),
);
return [token, secret];
}
async addToken(
user: User,
identifier: string,
validUntil: Date | null,
/**
* Creates a new API token for the given user
*
* @param userId The id of the user to create the token for
* @param tokenLabel The label of the token
* @param userDefinedValidUntil Maximum date until the token is valid, will be truncated to 2 years
* @throws TooManyTokensError if the user already has 200 tokens
* @returns The created token together with the secret
*/
async createToken(
userId: number,
tokenLabel: string,
userDefinedValidUntil?: Date,
): Promise<ApiTokenWithSecretDto> {
user.apiTokens = this.getTokensByUser(user);
return await this.knex.transaction(async (transaction) => {
const existingTokensForUser = await transaction(TableApiToken)
.select(FieldNameApiToken.id)
.where(FieldNameApiToken.userId, userId);
if (existingTokensForUser.length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
throw new TooManyTokensError(
`User '${userId}' has already 200 API tokens and can't have more`,
);
}
if ((await user.apiTokens).length >= 200) {
// This is a very high ceiling unlikely to hinder legitimate usage,
// but should prevent possible attack vectors
throw new TooManyTokensError(
`User '${user.username}' has already 200 API tokens and can't have more`,
const secret = bufferToBase64Url(randomBytes(64));
const keyId = bufferToBase64Url(randomBytes(8));
const accessTokenHash = hashApiToken(secret);
// Tokens can only be valid for a maximum of 2 years
const maximumTokenValidity = new Date();
maximumTokenValidity.setTime(
maximumTokenValidity.getTime() + 2 * 365 * 24 * 60 * 60 * 1000,
);
const isTokenLimitedToMaximumValidity =
!userDefinedValidUntil || userDefinedValidUntil > maximumTokenValidity;
const validUntil = isTokenLimitedToMaximumValidity
? maximumTokenValidity
: userDefinedValidUntil;
const token: TypeInsertApiToken = {
[FieldNameApiToken.id]: keyId,
[FieldNameApiToken.label]: tokenLabel,
[FieldNameApiToken.userId]: userId,
[FieldNameApiToken.secretHash]: accessTokenHash,
[FieldNameApiToken.validUntil]: validUntil,
[FieldNameApiToken.createdAt]: new Date(),
};
await this.knex(TableApiToken).insert(token);
return this.toAuthTokenWithSecretDto(
{
...token,
[FieldNameApiToken.lastUsedAt]: null,
},
secret,
);
}
const [token, secret] = this.createToken(user, identifier, validUntil);
const createdToken = (await this.authTokenRepository.save(
token,
)) as ApiToken;
return this.toAuthTokenWithSecretDto(
createdToken,
`${AUTH_TOKEN_PREFIX}.${createdToken.keyId}.${secret}`,
);
}
async setLastUsedToken(keyId: string): Promise<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
/**
* Ensures that the given token secret is valid for the given token
* This method does not return any value but throws an error if the token is not valid
*
* @param secret The secret to compare against the hash from the database
* @param tokenHash The hash from the database
* @param validUntil Expiry of the API token
* @throws TokenNotValidError if the token is invalid
*/
ensureTokenIsValid(
secret: string,
tokenHash: string,
validUntil: Date,
): void {
// First, verify token expiry is not in the past (cheap operation)
if (validUntil.getTime() < new Date().getTime()) {
throw new TokenNotValidError(
`Secret does not match Token ${token.label}.`,
`Auth token is not valid since ${validUntil.toISOString()}`,
);
}
if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) {
// tokens validUntil Date lies in the past
throw new TokenNotValidError(
`AuthToken '${
token.label
}' is not valid since ${token.validUntil.toISOString()}.`,
);
// Second, verify the secret (costly operation)
if (!checkTokenEquality(secret, tokenHash)) {
throw new TokenNotValidError(`Secret does not match token hash`);
}
}
async getTokensByUser(user: User): Promise<ApiToken[]> {
const tokens = await this.authTokenRepository.find({
where: { user: { id: user.id } },
});
if (tokens === null) {
return [];
}
return tokens;
/**
* Returns all tokens of a user
*
* @param userId The id of the user to get the tokens for
* @return The tokens of the user
*/
getTokensOfUserById(userId: number): Promise<ApiToken[]> {
return this.knex(TableApiToken)
.select()
.where(FieldNameApiToken.userId, userId);
}
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`);
/**
* 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');
}
await this.authTokenRepository.remove(token);
}
toAuthTokenDto(authToken: ApiToken): ApiTokenDto {
const tokenDto: ApiTokenDto = {
label: authToken.label,
keyId: authToken.keyId,
createdAt: authToken.createdAt.toISOString(),
validUntil: authToken.validUntil.toISOString(),
lastUsedAt: null,
/**
* Converts an ApiToken to an ApiTokenDto
*
* @param apiToken The token to convert
* @return The converted token
*/
toAuthTokenDto(apiToken: ApiToken): ApiTokenDto {
return {
label: apiToken[FieldNameApiToken.label],
keyId: apiToken[FieldNameApiToken.id],
createdAt: apiToken[FieldNameApiToken.createdAt].toISOString(),
validUntil: apiToken[FieldNameApiToken.validUntil].toISOString(),
lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt]?.toISOString() ?? null,
};
if (authToken.lastUsedAt) {
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt).toISOString();
}
return tokenDto;
}
/**
* Converts an ApiToken to an ApiTokenWithSecretDto
*
* @param apiToken The token to convert
* @param secret The secret of the token
* @return The converted token
*/
toAuthTokenWithSecretDto(
authToken: ApiToken,
apiToken: ApiToken,
secret: string,
): ApiTokenWithSecretDto {
const tokenDto = this.toAuthTokenDto(authToken);
const tokenDto = this.toAuthTokenDto(apiToken);
const fullToken = `${AUTH_TOKEN_PREFIX}.${tokenDto.keyId}.${secret}`;
return {
...tokenDto,
secret: secret,
secret: fullToken,
};
}
// Delete all non valid tokens every sunday on 3:00 AM
// Deletes all invalid tokens every sunday on 3:00 AM
@Cron('0 0 3 * * 0')
async handleCron(): Promise<void> {
return await this.removeInvalidTokens();
}
// Delete all non valid tokens 5 sec after startup
// Delete all invalid tokens 5 sec after startup
@Timeout(5000)
async handleTimeout(): Promise<void> {
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> {
const currentTime = new Date().getTime();
const tokens: ApiToken[] = await this.authTokenRepository.find();
let removedTokens = 0;
for (const token of tokens) {
if (token.validUntil && token.validUntil.getTime() <= currentTime) {
this.logger.debug(
`AuthToken '${token.keyId}' was removed`,
'removeInvalidTokens',
);
await this.authTokenRepository.remove(token);
removedTokens++;
}
}
const numberOfDeletedTokens = await this.knex(TableApiToken)
.where(FieldNameApiToken.validUntil, '<', new Date())
.delete();
this.logger.log(
`${removedTokens} invalid AuthTokens were purged from the DB.`,
`${numberOfDeletedTokens} invalid AuthTokens were purged from the DB.`,
'removeInvalidTokens',
);
}

View file

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

View file

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

View file

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

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(),
);
await this.usersService.updateUser(
user,
makeUsernameLowercase(loginDto.username),
userInfo.displayName,
userInfo.email,
userInfo.photoUrl,

View file

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

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
*/
@ -23,10 +23,10 @@ import { HistoryEntryDto } from '../../../../history/history-entry.dto';
import { HistoryService } from '../../../../history/history.service';
import { ConsoleLoggerService } from '../../../../logger/console-logger.service';
import { Note } from '../../../../notes/note.entity';
import { GetNoteInterceptor } from '../../../utils/get-note.interceptor';
import { GetNoteIdInterceptor } from '../../../utils/get-note-id.interceptor';
import { OpenApi } from '../../../utils/openapi.decorator';
import { RequestNote } from '../../../utils/request-note.decorator';
import { RequestUser } from '../../../utils/request-user.decorator';
import { RequestNoteId } from '../../../utils/request-note-id.decorator';
import { RequestUserId } from '../../../utils/request-user.decorator';
@UseGuards(SessionGuard)
@OpenApi(401)
@ -42,7 +42,7 @@ export class HistoryController {
@Get()
@OpenApi(200, 404)
async getHistory(@RequestUser() user: User): Promise<HistoryEntryDto[]> {
async getHistory(@RequestUserId() user: User): Promise<HistoryEntryDto[]> {
const foundEntries = await this.historyService.getEntriesByUser(user);
return await Promise.all(
foundEntries.map((entry) => this.historyService.toHistoryEntryDto(entry)),
@ -52,7 +52,7 @@ export class HistoryController {
@Post()
@OpenApi(201, 404)
async setHistory(
@RequestUser() user: User,
@RequestUserId() user: User,
@Body() historyImport: HistoryEntryImportListDto,
): Promise<void> {
await this.historyService.setHistory(user, historyImport.history);
@ -60,16 +60,16 @@ export class HistoryController {
@Delete()
@OpenApi(204, 404)
async deleteHistory(@RequestUser() user: User): Promise<void> {
async deleteHistory(@RequestUserId() user: User): Promise<void> {
await this.historyService.deleteHistory(user);
}
@Put(':noteIdOrAlias')
@Put(':noteAlias')
@OpenApi(200, 404)
@UseInterceptors(GetNoteInterceptor)
@UseInterceptors(GetNoteIdInterceptor)
async updateHistoryEntry(
@RequestNote() note: Note,
@RequestUser() user: User,
@RequestNoteId() note: Note,
@RequestUserId() user: User,
@Body() entryUpdateDto: HistoryEntryUpdateDto,
): Promise<HistoryEntryDto> {
const newEntry = await this.historyService.updateHistoryEntry(
@ -80,12 +80,12 @@ export class HistoryController {
return await this.historyService.toHistoryEntryDto(newEntry);
}
@Delete(':noteIdOrAlias')
@Delete(':noteAlias')
@OpenApi(204, 404)
@UseInterceptors(GetNoteInterceptor)
@UseInterceptors(GetNoteIdInterceptor)
async deleteHistoryEntry(
@RequestNote() note: Note,
@RequestUser() user: User,
@RequestNoteId() note: Note,
@RequestUserId() user: User,
): Promise<void> {
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 { SessionGuard } from '../../../auth/session.guard';
import { User } from '../../../database/user.entity';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service';
import { User } from '../../../users/user.entity';
import { UsersService } from '../../../users/users.service';
import { OpenApi } from '../../utils/openapi.decorator';
import { RequestUser } from '../../utils/request-user.decorator';
import { RequestUserInfo } from '../../utils/request-user-id.decorator';
import { SessionAuthProvider } from '../../utils/session-authprovider.decorator';
@UseGuards(SessionGuard)
@ -37,41 +35,42 @@ export class MeController {
@Get()
@OpenApi(200)
getMe(
@RequestUser() user: User,
@RequestUserInfo() userId: number,
@SessionAuthProvider() authProvider: ProviderType,
): LoginUserInfoDto {
return this.userService.toLoginUserInfoDto(user, authProvider);
return this.userService.toLoginUserInfoDto(userId, authProvider);
}
@Get('media')
@OpenApi(200)
async getMyMedia(@RequestUser() user: User): Promise<MediaUploadDto[]> {
const media = await this.mediaService.listUploadsByUser(user);
async getMyMedia(@RequestUserInfo() user: User): Promise<MediaUploadDto[]> {
const media = await this.mediaService.getMediaUploadUuidsByUserId(user);
return await Promise.all(
media.map((media) => this.mediaService.toMediaUploadDto(media)),
media.map((media) => this.mediaService.getMediaUploadDtosByUuids(media)),
);
}
@Delete()
@OpenApi(204, 404, 500)
async deleteUser(@RequestUser() user: User): Promise<void> {
const mediaUploads = await this.mediaService.listUploadsByUser(user);
async deleteUser(@RequestUserInfo() userId: number): Promise<void> {
const mediaUploads =
await this.mediaService.getMediaUploadUuidsByUserId(userId);
for (const mediaUpload of mediaUploads) {
await this.mediaService.deleteFile(mediaUpload);
}
this.logger.debug(`Deleted all media uploads of ${user.username}`);
await this.userService.deleteUser(user);
this.logger.debug(`Deleted ${user.username}`);
this.logger.debug(`Deleted all media uploads for user with id ${userId}`);
await this.userService.deleteUser(userId);
this.logger.debug(`Deleted user with id ${userId}`);
}
@Put('profile')
@OpenApi(200)
async updateProfile(
@RequestUser() user: User,
@RequestUserInfo() userId: number,
@Body('displayName') newDisplayName: string,
): Promise<void> {
await this.userService.updateUser(
user,
userId,
newDisplayName,
undefined,
undefined,

View file

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

View file

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

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
*/
import { Module } from '@nestjs/common';
import { AliasModule } from '../../alias/alias.module';
import { ApiTokenModule } from '../../api-token/api-token.module';
import { AuthModule } from '../../auth/auth.module';
import { FrontendConfigModule } from '../../frontend-config/frontend-config.module';
import { GroupsModule } from '../../groups/groups.module';
import { HistoryModule } from '../../history/history.module';
import { LoggerModule } from '../../logger/logger.module';
import { MediaModule } from '../../media/media.module';
import { NotesModule } from '../../notes/notes.module';
import { PermissionsModule } from '../../permissions/permissions.module';
import { RevisionsModule } from '../../revisions/revisions.module';
import { UsersModule } from '../../users/users.module';
import { AliasController } from './alias/alias.controller';
import { ApiTokensController } from './api-tokens/api-tokens.controller';
import { AuthController } from './auth/auth.controller';
import { GuestController } from './auth/guest/guest.controller';
import { LdapController } from './auth/ldap/ldap.controller';
import { LocalController } from './auth/local/local.controller';
import { OidcController } from './auth/oidc/oidc.controller';
@ -27,7 +28,6 @@ import { HistoryController } from './me/history/history.controller';
import { MeController } from './me/me.controller';
import { MediaController } from './media/media.controller';
import { NotesController } from './notes/notes.controller';
import { ApiTokensController } from './tokens/api-tokens.controller';
import { UsersController } from './users/users.controller';
@Module({
@ -36,9 +36,8 @@ import { UsersController } from './users/users.controller';
UsersModule,
ApiTokenModule,
FrontendConfigModule,
HistoryModule,
PermissionsModule,
NotesModule,
AliasModule,
MediaModule,
RevisionsModule,
AuthModule,
@ -47,6 +46,7 @@ import { UsersController } from './users/users.controller';
controllers: [
ApiTokensController,
ConfigController,
GuestController,
MediaController,
HistoryController,
MeController,

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

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

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

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

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

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

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

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
*/
@ -14,7 +14,6 @@ import { ErrorExceptionMapping } from './errors/error-mapping';
import { ConsoleLoggerService } from './logger/console-logger.service';
import { BackendType } from './media/backends/backend-type.enum';
import { SessionService } from './sessions/session.service';
import { setupSpecialGroups } from './utils/createSpecialGroups';
import { setupSessionMiddleware } from './utils/session';
import { setupValidationPipe } from './utils/setup-pipes';
import { setupPrivateApiDocs, setupPublicApiDocs } from './utils/swagger';
@ -29,12 +28,12 @@ export async function setupApp(
mediaConfig: MediaConfig,
logger: ConsoleLoggerService,
): Promise<void> {
// Setup OpenAPI documentation
await setupPublicApiDocs(app);
logger.log(
`Serving OpenAPI docs for public API under '/api/doc/v2'`,
'AppBootstrap',
);
if (process.env.NODE_ENV === 'development') {
await setupPrivateApiDocs(app);
logger.log(
@ -43,14 +42,14 @@ export async function setupApp(
);
}
await setupSpecialGroups(app);
// Setup session handling
setupSessionMiddleware(
app,
authConfig,
app.get(SessionService).getTypeormStore(),
app.get(SessionService).getSessionStore(),
);
// Enable web security aspects
app.enableCors({
origin: appConfig.rendererBaseUrl,
});
@ -58,9 +57,14 @@ export async function setupApp(
`Enabling CORS for '${appConfig.rendererBaseUrl}'`,
'AppBootstrap',
);
// TODO Add rate limiting (#442)
// TODO Add CSP (#1309)
// TODO Add common security headers and CSRF (#201)
// Setup class-validator for incoming API request data
app.useGlobalPipes(setupValidationPipe(logger));
// Map URL paths to directories
if (mediaConfig.backend.use === BackendType.FILESYSTEM) {
logger.log(
`Serving the local folder '${mediaConfig.backend.filesystem.uploadPath}' under '/uploads'`,
@ -70,7 +74,6 @@ export async function setupApp(
prefix: '/uploads/',
});
}
logger.log(
`Serving the local folder 'public' under '/public'`,
'AppBootstrap',
@ -78,9 +81,14 @@ export async function setupApp(
app.useStaticAssets('public', {
prefix: '/public/',
});
// TODO Evaluate whether we really need this folder,
// only use-cases for now are intro.md and motd.md which could be API endpoints as well
// Configure WebSocket and error message handling
const { httpAdapter } = app.get(HttpAdapterHost);
app.useGlobalFilters(new ErrorExceptionMapping(httpAdapter));
app.useGlobalFilters(new ErrorExceptionMapping(logger, httpAdapter));
app.useWebSocketAdapter(new WsAdapter(app));
// Enable hooks on app shutdown, like saving notes into the database
app.enableShutdownHooks();
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,71 +3,40 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ProviderType } from '@hedgedoc/commons';
import { GuestAccess } from '@hedgedoc/commons';
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { CompleteRequest } from '../api/utils/request.type';
import noteConfiguration, { NoteConfig } from '../config/note.config';
import { NotInDBError } from '../errors/errors';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { SessionState } from '../sessions/session.service';
import { UsersService } from '../users/users.service';
export type RequestWithSession = Request & {
session: SessionState;
};
/**
* This guard checks if a session is present.
*
* If there is a username in `request.session.username` it will try to get this user from the database and put it into `request.user`. See {@link RequestUser}.
* If there is no `request.session.username`, but any GuestAccess is configured, `request.session.authProvider` is set to `guest` to indicate a guest user.
* If there is no `request.session.username`, but any PermissionLevel is configured, `request.session.authProvider` is set to `guest` to indicate a guest user.
*
* @throws UnauthorizedException
*/
@Injectable()
export class SessionGuard implements CanActivate {
constructor(
private readonly logger: ConsoleLoggerService,
private userService: UsersService,
@Inject(noteConfiguration.KEY)
private noteConfig: NoteConfig,
) {
constructor(private readonly logger: ConsoleLoggerService) {
this.logger.setContext(SessionGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
canActivate(context: ExecutionContext): boolean {
const request: CompleteRequest = context.switchToHttp().getRequest();
const username = request.session?.username;
if (!username) {
if (this.noteConfig.guestAccess !== GuestAccess.DENY && request.session) {
if (!request.session.authProviderType) {
request.session.authProviderType = ProviderType.GUEST;
}
return true;
}
const userId = request.session?.userId;
const authProviderType = request.session?.authProviderType;
if (!userId || !authProviderType) {
this.logger.debug('The user has no session.');
throw new UnauthorizedException("You're not logged in");
}
try {
request.user = await this.userService.getUserByUsername(username);
return true;
} catch (e) {
if (e instanceof NotInDBError) {
this.logger.debug(
`The user '${username}' does not exist, but has a session.`,
);
throw new UnauthorizedException("You're not logged in");
}
throw e;
}
request.userId = userId;
request.authProviderType = authProviderType;
return true;
}
}

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
*/
import { GuestAccess } from '@hedgedoc/commons';
import { PermissionLevel } from '@hedgedoc/commons';
import { ConfigFactoryKeyHost, registerAs } from '@nestjs/config';
import { ConfigFactory } from '@nestjs/config/dist/interfaces';
@ -20,7 +20,7 @@ export function createDefaultMockNoteConfig(): NoteConfig {
loggedIn: DefaultAccessLevel.WRITE,
},
},
guestAccess: GuestAccess.CREATE,
guestAccess: PermissionLevel.CREATE,
revisionRetentionDays: 0,
};
}

View file

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

View file

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

View file

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

View file

@ -36,6 +36,10 @@ export async function seed(knex: Knex): Promise<void> {
await knex(TableNoteGroupPermission).del();
await knex(TableNoteUserPermission).del();
const guestNoteRevisionUuid = '0196a6e7-9669-7ef3-9c10-520734c61593';
const userNoteRevisionUuid = '0196a6e8-f63e-7473-bf58-ea97e937fde2';
const userSlideRevisionUuid = '0196a6e9-1152-7940-a531-01b9527321c0';
const guestNoteAlias = 'guest-note';
const userNoteAlias = 'user-note';
const userSlideAlias = 'user-slide';
@ -94,6 +98,7 @@ export async function seed(knex: Knex): Promise<void> {
]);
await knex(TableRevision).insert([
{
[FieldNameRevision.uuid]: guestNoteRevisionUuid,
[FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch(
guestNoteAlias,
@ -107,6 +112,7 @@ export async function seed(knex: Knex): Promise<void> {
[FieldNameRevision.description]: guestNoteDescription,
},
{
[FieldNameRevision.uuid]: userNoteRevisionUuid,
[FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch(
userNoteAlias,
@ -120,6 +126,7 @@ export async function seed(knex: Knex): Promise<void> {
[FieldNameRevision.description]: userNoteDescription,
},
{
[FieldNameRevision.uuid]: userSlideRevisionUuid,
[FieldNameRevision.noteId]: 1,
[FieldNameRevision.patch]: createPatch(
userSlideAlias,
@ -135,33 +142,33 @@ export async function seed(knex: Knex): Promise<void> {
]);
await knex(TableRevisionTag).insert([
...guestNoteTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 1,
[FieldNameRevisionTag.revisionUuid]: guestNoteRevisionUuid,
[FieldNameRevisionTag.tag]: tag,
})),
...userNoteTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 2,
[FieldNameRevisionTag.revisionUuid]: userNoteRevisionUuid,
[FieldNameRevisionTag.tag]: tag,
})),
...userSlideTags.map((tag) => ({
[FieldNameRevisionTag.revisionId]: 3,
[FieldNameRevisionTag.revisionUuid]: userSlideRevisionUuid,
[FieldNameRevisionTag.tag]: tag,
})),
]);
await knex(TableAuthorshipInfo).insert([
{
[FieldNameAuthorshipInfo.revisionId]: 1,
[FieldNameAuthorshipInfo.revisionUuid]: guestNoteRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 1,
[FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: guestNoteContent.length,
},
{
[FieldNameAuthorshipInfo.revisionId]: 2,
[FieldNameAuthorshipInfo.revisionUuid]: userNoteRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 2,
[FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: userNoteContent.length,
},
{
[FieldNameAuthorshipInfo.revisionId]: 3,
[FieldNameAuthorshipInfo.revisionUuid]: userSlideRevisionUuid,
[FieldNameAuthorshipInfo.authorId]: 2,
[FieldNameAuthorshipInfo.startPosition]: 0,
[FieldNameAuthorshipInfo.endPosition]: userSlideContent.length,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Username } from '../../utils/username';
/**
* The user object represents either a registered user in the instance or a guest user.
@ -21,13 +20,13 @@ export interface User {
[FieldNameUser.id]: number;
/** The user's chosen username or null if it is a guest user */
[FieldNameUser.username]: Username | null;
[FieldNameUser.username]: string | null;
/** The guest user's UUID or null if it is a registered user */
[FieldNameUser.guestUuid]: string | null;
/** The user's chosen display name */
[FieldNameUser.displayName]: string | null;
[FieldNameUser.displayName]: string;
/** Timestamp when the user was created */
[FieldNameUser.createdAt]: Date;

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

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
*/
export class NotInDBError extends Error {
export class ErrorWithContextDetails extends Error {
constructor(
message?: string,
public readonly classContext?: string,
public readonly functionContext?: string,
) {
super(message);
}
}
export class NotInDBError extends ErrorWithContextDetails {
name = 'NotInDBError';
}
export class AlreadyInDBError extends Error {
export class AlreadyInDBError extends ErrorWithContextDetails {
name = 'AlreadyInDBError';
}
export class ForbiddenIdError extends Error {
export class GenericDBError extends ErrorWithContextDetails {
name = 'GenericDBError';
}
export class ForbiddenIdError extends ErrorWithContextDetails {
name = 'ForbiddenIdError';
}
export class ClientError extends Error {
export class ClientError extends ErrorWithContextDetails {
name = 'ClientError';
}
export class PermissionError extends Error {
export class PermissionError extends ErrorWithContextDetails {
name = 'PermissionError';
}
export class TokenNotValidError extends Error {
export class TokenNotValidError extends ErrorWithContextDetails {
name = 'TokenNotValidError';
}
export class TooManyTokensError extends Error {
export class TooManyTokensError extends ErrorWithContextDetails {
name = 'TooManyTokensError';
}
export class PermissionsUpdateInconsistentError extends Error {
export class PermissionsUpdateInconsistentError extends ErrorWithContextDetails {
name = 'PermissionsUpdateInconsistentError';
}
export class MediaBackendError extends Error {
export class MediaBackendError extends ErrorWithContextDetails {
name = 'MediaBackendError';
}
export class PrimaryAliasDeletionForbiddenError extends Error {
export class PrimaryAliasDeletionForbiddenError extends ErrorWithContextDetails {
name = 'PrimaryAliasDeletionForbiddenError';
}
export class InvalidCredentialsError extends Error {
export class InvalidCredentialsError extends ErrorWithContextDetails {
name = 'InvalidCredentialsError';
}
export class NoLocalIdentityError extends Error {
export class NoLocalIdentityError extends ErrorWithContextDetails {
name = 'NoLocalIdentityError';
}
export class PasswordTooWeakError extends Error {
export class PasswordTooWeakError extends ErrorWithContextDetails {
name = 'PasswordTooWeakError';
}
export class MaximumDocumentLengthExceededError extends Error {
export class MaximumDocumentLengthExceededError extends ErrorWithContextDetails {
name = 'MaximumDocumentLengthExceededError';
}
export class FeatureDisabledError extends Error {
export class FeatureDisabledError extends ErrorWithContextDetails {
name = 'FeatureDisabledError';
}

View file

@ -16,8 +16,19 @@ export const eventModuleConfig = {
};
export enum NoteEvent {
PERMISSION_CHANGE = 'note.permission_change' /** noteId: The id of the [@link Note], which permissions are changed. **/,
DELETION = 'note.deletion' /** noteId: The id of the [@link Note], which is being deleted. **/,
/**
* Event triggered when a note's permissions are changed.
* Payload:
* noteId: The id of the {@link Note}, for which permissions are changed.
*/
PERMISSION_CHANGE = 'note.permission_change',
/**
* Event triggered when a note is deleted
* Payload:
* noteId: The id of the {@link Note}, which is being deleted.
*/
DELETION = 'note.deletion',
}
export interface NoteEventMap extends EventMap {

View file

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

View file

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

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

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

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

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
*/
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KnexModule } from 'nest-knexjs';
import { LoggerModule } from '../logger/logger.module';
import { NotesModule } from '../notes/notes.module';
import { UsersModule } from '../users/users.module';
import { NoteModule } from '../note/note.module';
import { AzureBackend } from './backends/azure-backend';
import { FilesystemBackend } from './backends/filesystem-backend';
import { ImgurBackend } from './backends/imgur-backend';
import { S3Backend } from './backends/s3-backend';
import { WebdavBackend } from './backends/webdav-backend';
import { MediaUpload } from './media-upload.entity';
import { MediaService } from './media.service';
@Module({
imports: [
TypeOrmModule.forFeature([MediaUpload]),
NotesModule,
UsersModule,
LoggerModule,
ConfigModule,
],
imports: [NoteModule, LoggerModule, ConfigModule, KnexModule],
providers: [
MediaService,
FilesystemBackend,

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

View file

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

View file

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

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

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
*/
import { randomBytes } from 'crypto';
import { User } from '../database/user.entity';
import { Alias } from './alias.entity';
import { Alias } from './aliases.entity';
import { Note } from './note.entity';
import { generatePublicId, getPrimaryAlias } from './utils';
import { generateRandomAlias, getPrimaryAlias } from './utils';
jest.mock('crypto');
const random128bitBuffer = Buffer.from([
@ -19,7 +19,7 @@ const mockRandomBytes = randomBytes as jest.MockedFunction<typeof randomBytes>;
mockRandomBytes.mockImplementation((_) => random128bitBuffer);
it('generatePublicId', () => {
expect(generatePublicId()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc');
expect(generateRandomAlias()).toEqual('w5trddy3zc1tj9mzs7b8rbbvfc');
});
describe('getPrimaryAlias', () => {
@ -29,11 +29,11 @@ describe('getPrimaryAlias', () => {
const user = User.create('hardcoded', 'Testy') as User;
note = Note.create(user, alias) as Note;
});
it('finds correct primary alias', async () => {
it('finds correct primary aliases', async () => {
(await note.aliases).push(Alias.create('annother', note, false) as Alias);
expect(await getPrimaryAlias(note)).toEqual(alias);
});
it('returns undefined if there is no alias', async () => {
it('returns undefined if there is no aliases', async () => {
(await note.aliases)[0].primary = false;
expect(await getPrimaryAlias(note)).toEqual(undefined);
});

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
*/
import base32Encode from 'base32-encode';
import { randomBytes } from 'crypto';
import { Alias } from './alias.entity';
import { Alias } from './aliases.entity';
import { Note } from './note.entity';
/**
* Generate publicId for a note.
* This is a randomly generated 128-bit value encoded with base32-encode using the crockford variant and converted to lowercase.
*/
export function generatePublicId(): string {
const randomId = randomBytes(16);
return base32Encode(randomId, 'Crockford').toLowerCase();
}
/**
* Extract the primary alias from a aliases of a note
* @param {Note} note - the note from which the primary alias should be extracted
* Extract the primary aliases from a aliases of a note
* @param {Note} note - the note from which the primary aliases should be extracted
*/
export async function getPrimaryAlias(note: Note): Promise<string | undefined> {
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
*/
import {
getNotePermissionDisplayName,
NotePermission,
getNotePermissionLevelDisplayName,
NotePermissionLevel,
} from './note-permission.enum';
describe('note permission order', () => {
it('DENY is less than READ', () => {
expect(NotePermission.DENY < NotePermission.READ).toBeTruthy();
expect(NotePermissionLevel.DENY < NotePermissionLevel.READ).toBeTruthy();
});
it('READ is less than WRITE', () => {
expect(NotePermission.READ < NotePermission.WRITE).toBeTruthy();
expect(NotePermissionLevel.READ < NotePermissionLevel.WRITE).toBeTruthy();
});
it('WRITE is less than OWNER', () => {
expect(NotePermission.WRITE < NotePermission.OWNER).toBeTruthy();
expect(NotePermissionLevel.WRITE < NotePermissionLevel.OWNER).toBeTruthy();
});
});
describe('getNotePermissionDisplayName', () => {
it.each([
['deny', NotePermission.DENY],
['read', NotePermission.READ],
['write', NotePermission.WRITE],
['owner', NotePermission.OWNER],
['deny', NotePermissionLevel.DENY],
['read', NotePermissionLevel.READ],
['write', NotePermissionLevel.WRITE],
['owner', NotePermissionLevel.OWNER],
])('displays %s correctly', (displayName, permission) => {
expect(getNotePermissionDisplayName(permission)).toBe(displayName);
expect(getNotePermissionLevelDisplayName(permission)).toBe(displayName);
});
});

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

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

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

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
*/
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { KnexModule } from 'nest-knexjs';
import { GroupsModule } from '../groups/groups.module';
import { LoggerModule } from '../logger/logger.module';
import { Note } from '../notes/note.entity';
import { UsersModule } from '../users/users.module';
import { PermissionsService } from './permissions.service';
import { PermissionService } from './permission.service';
@Module({
imports: [
TypeOrmModule.forFeature([Note]),
UsersModule,
GroupsModule,
LoggerModule,
],
exports: [PermissionsService],
providers: [PermissionsService],
imports: [KnexModule, LoggerModule],
exports: [PermissionService],
providers: [PermissionService],
})
export class PermissionsModule {}

View file

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

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