mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-09 13:51:57 -04:00
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:
parent
6e151c8a1b
commit
7adce05412
198 changed files with 3865 additions and 5899 deletions
|
@ -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",
|
||||
|
|
19
backend/src/alias/alias.module.ts
Normal file
19
backend/src/alias/alias.module.ts
Normal 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 {}
|
|
@ -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);
|
276
backend/src/alias/alias.service.ts
Normal file
276
backend/src/alias/alias.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 (
|
||||
|
|
|
@ -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],
|
||||
})
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,21 +63,60 @@ 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];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new API token for the given user
|
||||
*
|
||||
* @param userId The id of the user to create the token for
|
||||
* @param tokenLabel The label of the token
|
||||
* @param userDefinedValidUntil Maximum date until the token is valid, will be truncated to 2 years
|
||||
* @throws TooManyTokensError if the user already has 200 tokens
|
||||
* @returns The created token together with the secret
|
||||
*/
|
||||
async createToken(
|
||||
userId: number,
|
||||
tokenLabel: string,
|
||||
userDefinedValidUntil?: Date,
|
||||
): Promise<ApiTokenWithSecretDto> {
|
||||
return await this.knex.transaction(async (transaction) => {
|
||||
const existingTokensForUser = await transaction(TableApiToken)
|
||||
.select(FieldNameApiToken.id)
|
||||
.where(FieldNameApiToken.userId, userId);
|
||||
if (existingTokensForUser.length >= 200) {
|
||||
// This is a very high ceiling unlikely to hinder legitimate usage,
|
||||
// but should prevent possible attack vectors
|
||||
throw new TooManyTokensError(
|
||||
`User '${userId}' has already 200 API tokens and can't have more`,
|
||||
);
|
||||
}
|
||||
|
||||
createToken(
|
||||
user: User,
|
||||
identifier: string,
|
||||
userDefinedValidUntil: Date | null,
|
||||
): [Omit<ApiToken, 'id' | 'createdAt'>, string] {
|
||||
const secret = bufferToBase64Url(randomBytes(64));
|
||||
const keyId = bufferToBase64Url(randomBytes(8));
|
||||
// More about the choice of SHA-512 in the dev docs
|
||||
const accessTokenHash = createHash('sha512').update(secret).digest('hex');
|
||||
const accessTokenHash = hashApiToken(secret);
|
||||
// Tokens can only be valid for a maximum of 2 years
|
||||
const maximumTokenValidity = new Date();
|
||||
maximumTokenValidity.setTime(
|
||||
|
@ -69,154 +127,138 @@ export class ApiTokenService {
|
|||
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,
|
||||
): Promise<ApiTokenWithSecretDto> {
|
||||
user.apiTokens = this.getTokensByUser(user);
|
||||
|
||||
if ((await user.apiTokens).length >= 200) {
|
||||
// This is a very high ceiling unlikely to hinder legitimate usage,
|
||||
// but should prevent possible attack vectors
|
||||
throw new TooManyTokensError(
|
||||
`User '${user.username}' has already 200 API tokens and can't have more`,
|
||||
);
|
||||
}
|
||||
const [token, secret] = this.createToken(user, identifier, validUntil);
|
||||
const createdToken = (await this.authTokenRepository.save(
|
||||
token,
|
||||
)) as ApiToken;
|
||||
return this.toAuthTokenWithSecretDto(
|
||||
createdToken,
|
||||
`${AUTH_TOKEN_PREFIX}.${createdToken.keyId}.${secret}`,
|
||||
);
|
||||
}
|
||||
|
||||
async setLastUsedToken(keyId: string): Promise<void> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`API token with id '${keyId}' not found`);
|
||||
}
|
||||
token.lastUsedAt = new Date();
|
||||
await this.authTokenRepository.save(token);
|
||||
}
|
||||
|
||||
async getToken(keyId: string): Promise<ApiToken> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`API token with id '${keyId}' not found`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
checkToken(secret: string, token: ApiToken): void {
|
||||
if (!checkTokenEquality(secret, token.hash)) {
|
||||
// hashes are not the same
|
||||
throw new TokenNotValidError(
|
||||
`Secret does not match Token ${token.label}.`,
|
||||
);
|
||||
}
|
||||
if (token.validUntil && token.validUntil.getTime() < new Date().getTime()) {
|
||||
// tokens validUntil Date lies in the past
|
||||
throw new TokenNotValidError(
|
||||
`AuthToken '${
|
||||
token.label
|
||||
}' is not valid since ${token.validUntil.toISOString()}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getTokensByUser(user: User): Promise<ApiToken[]> {
|
||||
const tokens = await this.authTokenRepository.find({
|
||||
where: { user: { id: user.id } },
|
||||
});
|
||||
if (tokens === null) {
|
||||
return [];
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async removeToken(keyId: string): Promise<void> {
|
||||
const token = await this.authTokenRepository.findOne({
|
||||
where: { keyId: keyId },
|
||||
});
|
||||
if (token === null) {
|
||||
throw new NotInDBError(`API token with id '${keyId}' not found`);
|
||||
}
|
||||
await this.authTokenRepository.remove(token);
|
||||
}
|
||||
|
||||
toAuthTokenDto(authToken: ApiToken): ApiTokenDto {
|
||||
const tokenDto: ApiTokenDto = {
|
||||
label: authToken.label,
|
||||
keyId: authToken.keyId,
|
||||
createdAt: authToken.createdAt.toISOString(),
|
||||
validUntil: authToken.validUntil.toISOString(),
|
||||
lastUsedAt: null,
|
||||
const token: TypeInsertApiToken = {
|
||||
[FieldNameApiToken.id]: keyId,
|
||||
[FieldNameApiToken.label]: tokenLabel,
|
||||
[FieldNameApiToken.userId]: userId,
|
||||
[FieldNameApiToken.secretHash]: accessTokenHash,
|
||||
[FieldNameApiToken.validUntil]: validUntil,
|
||||
[FieldNameApiToken.createdAt]: new Date(),
|
||||
};
|
||||
|
||||
if (authToken.lastUsedAt) {
|
||||
tokenDto.lastUsedAt = new Date(authToken.lastUsedAt).toISOString();
|
||||
await this.knex(TableApiToken).insert(token);
|
||||
return this.toAuthTokenWithSecretDto(
|
||||
{
|
||||
...token,
|
||||
[FieldNameApiToken.lastUsedAt]: null,
|
||||
},
|
||||
secret,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return tokenDto;
|
||||
/**
|
||||
* Ensures that the given token secret is valid for the given token
|
||||
* This method does not return any value but throws an error if the token is not valid
|
||||
*
|
||||
* @param secret The secret to compare against the hash from the database
|
||||
* @param tokenHash The hash from the database
|
||||
* @param validUntil Expiry of the API token
|
||||
* @throws TokenNotValidError if the token is invalid
|
||||
*/
|
||||
ensureTokenIsValid(
|
||||
secret: string,
|
||||
tokenHash: string,
|
||||
validUntil: Date,
|
||||
): void {
|
||||
// First, verify token expiry is not in the past (cheap operation)
|
||||
if (validUntil.getTime() < new Date().getTime()) {
|
||||
throw new TokenNotValidError(
|
||||
`Auth token is not valid since ${validUntil.toISOString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Second, verify the secret (costly operation)
|
||||
if (!checkTokenEquality(secret, tokenHash)) {
|
||||
throw new TokenNotValidError(`Secret does not match token hash`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all tokens of a user
|
||||
*
|
||||
* @param userId The id of the user to get the tokens for
|
||||
* @return The tokens of the user
|
||||
*/
|
||||
getTokensOfUserById(userId: number): Promise<ApiToken[]> {
|
||||
return this.knex(TableApiToken)
|
||||
.select()
|
||||
.where(FieldNameApiToken.userId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a token from the database
|
||||
*
|
||||
* @param keyId The id of the token to remove
|
||||
* @param userId The id of the user who owns the token
|
||||
* @throws NotInDBError if the token is not found
|
||||
*/
|
||||
async removeToken(keyId: string, userId: number): Promise<void> {
|
||||
const numberOfDeletedTokens = await this.knex(TableApiToken)
|
||||
.where(FieldNameApiToken.id, keyId)
|
||||
.andWhere(FieldNameApiToken.userId, userId)
|
||||
.delete();
|
||||
if (numberOfDeletedTokens === 0) {
|
||||
throw new NotInDBError('Token not found');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ApiToken to an ApiTokenDto
|
||||
*
|
||||
* @param apiToken The token to convert
|
||||
* @return The converted token
|
||||
*/
|
||||
toAuthTokenDto(apiToken: ApiToken): ApiTokenDto {
|
||||
return {
|
||||
label: apiToken[FieldNameApiToken.label],
|
||||
keyId: apiToken[FieldNameApiToken.id],
|
||||
createdAt: apiToken[FieldNameApiToken.createdAt].toISOString(),
|
||||
validUntil: apiToken[FieldNameApiToken.validUntil].toISOString(),
|
||||
lastUsedAt: apiToken[FieldNameApiToken.lastUsedAt]?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an ApiToken to an ApiTokenWithSecretDto
|
||||
*
|
||||
* @param apiToken The token to convert
|
||||
* @param secret The secret of the token
|
||||
* @return The converted token
|
||||
*/
|
||||
toAuthTokenWithSecretDto(
|
||||
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',
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,9 +91,10 @@ export class AuthController {
|
|||
) {
|
||||
throw new BadRequestException('No pending user data');
|
||||
}
|
||||
const identity = await this.identityService.createUserWithIdentity(
|
||||
const identity =
|
||||
await this.identityService.createUserWithIdentityFromPendingUserConfirmation(
|
||||
request.session.newUserData,
|
||||
updatedUserInfo,
|
||||
pendingUserConfirmationData,
|
||||
request.session.authProviderType,
|
||||
request.session.authProviderIdentifier,
|
||||
request.session.providerUserId,
|
||||
|
|
58
backend/src/api/private/auth/guest/guest.controller.ts
Normal file
58
backend/src/api/private/auth/guest/guest.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -66,7 +66,7 @@ export class LdapController {
|
|||
loginDto.username.toLowerCase(),
|
||||
);
|
||||
await this.usersService.updateUser(
|
||||
user,
|
||||
makeUsernameLowercase(loginDto.username),
|
||||
userInfo.displayName,
|
||||
userInfo.email,
|
||||
userInfo.photoUrl,
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!');
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}'`,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -31,7 +31,7 @@ import {
|
|||
okDescription,
|
||||
payloadTooLargeDescription,
|
||||
unauthorizedDescription,
|
||||
} from './descriptions';
|
||||
} from '../descriptions';
|
||||
|
||||
export type HttpStatusCodes =
|
||||
| 200
|
|
@ -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;
|
||||
},
|
||||
);
|
|
@ -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;
|
||||
},
|
||||
);
|
|
@ -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
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
30
backend/src/api/utils/extract-note-id-from-request.ts
Normal file
30
backend/src/api/utils/extract-note-id-from-request.ts
Normal 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;
|
||||
}
|
33
backend/src/api/utils/guards/guests-enabled.guard.ts
Normal file
33
backend/src/api/utils/guards/guests-enabled.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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],
|
||||
|
|
|
@ -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(
|
||||
return await this.createUserWithIdentity(
|
||||
authProviderType,
|
||||
authProviderIdentifier,
|
||||
authProviderUserId,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
request.userId = userId;
|
||||
request.authProviderType = authProviderType;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
@ -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'.`,
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -29,4 +29,5 @@ export enum FieldNameAlias {
|
|||
|
||||
export const TableAlias = 'alias';
|
||||
|
||||
export type TypeInsertAlias = Alias;
|
||||
export type TypeUpdateAlias = Pick<Alias, FieldNameAlias.isPrimary>;
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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],
|
||||
})
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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([]);
|
||||
|
|
|
@ -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) {
|
||||
userId: User[FieldNameUser.id],
|
||||
noteId: Note[FieldNameNote.id],
|
||||
): Promise<MediaUpload[FieldNameMediaUpload.uuid]> {
|
||||
this.logger.debug(
|
||||
`Saving file for note '${note.id}' and user '${user.username}'`,
|
||||
`Saving file for note '${noteId}' and user '${userId}'`,
|
||||
'saveFile',
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`Saving file for note '${note.id}' and not logged in user`,
|
||||
'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> {
|
||||
await this.mediaBackend.deleteFile(
|
||||
mediaUpload.uuid,
|
||||
mediaUpload.backendData,
|
||||
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.mediaUploadRepository.remove(mediaUpload);
|
||||
}
|
||||
await this.mediaBackend.deleteFile(
|
||||
uuid,
|
||||
backendData[FieldNameMediaUpload.backendData],
|
||||
);
|
||||
await this.knex(TableMediaUpload)
|
||||
.where(FieldNameMediaUpload.uuid, uuid)
|
||||
.delete();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -170,10 +194,9 @@ export class MediaService {
|
|||
* @throws {NotInDBError} - the MediaUpload entity with the provided UUID is not found in the database.
|
||||
*/
|
||||
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],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
31
backend/src/notes/note.module.ts
Normal file
31
backend/src/notes/note.module.ts
Normal 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 {}
|
|
@ -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);
|
369
backend/src/notes/note.service.ts
Normal file
369
backend/src/notes/note.service.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 {}
|
|
@ -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: [],
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
474
backend/src/permissions/permission.service.ts
Normal file
474
backend/src/permissions/permission.service.ts
Normal 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],
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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],
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue