diff --git a/src/api/private/alias/alias.controller.spec.ts b/src/api/private/alias/alias.controller.spec.ts new file mode 100644 index 000000000..c272c4298 --- /dev/null +++ b/src/api/private/alias/alias.controller.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2021 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 { + getConnectionToken, + getRepositoryToken, + TypeOrmModule, +} from '@nestjs/typeorm'; + +import { AuthToken } from '../../../auth/auth-token.entity'; +import { Author } from '../../../authors/author.entity'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; +import { Group } from '../../../groups/group.entity'; +import { GroupsModule } from '../../../groups/groups.module'; +import { HistoryEntry } from '../../../history/history-entry.entity'; +import { HistoryModule } from '../../../history/history.module'; +import { Identity } from '../../../identity/identity.entity'; +import { LoggerModule } from '../../../logger/logger.module'; +import { MediaUpload } from '../../../media/media-upload.entity'; +import { MediaModule } from '../../../media/media.module'; +import { Alias } from '../../../notes/alias.entity'; +import { AliasService } from '../../../notes/alias.service'; +import { Note } from '../../../notes/note.entity'; +import { NotesService } from '../../../notes/notes.service'; +import { Tag } from '../../../notes/tag.entity'; +import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../../permissions/note-user-permission.entity'; +import { PermissionsModule } from '../../../permissions/permissions.module'; +import { Edit } from '../../../revisions/edit.entity'; +import { Revision } from '../../../revisions/revision.entity'; +import { RevisionsModule } from '../../../revisions/revisions.module'; +import { Session } from '../../../users/session.entity'; +import { User } from '../../../users/user.entity'; +import { UsersModule } from '../../../users/users.module'; +import { AliasController } from './alias.controller'; + +describe('AliasController', () => { + let controller: AliasController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AliasController], + providers: [ + AliasService, + NotesService, + { + provide: getRepositoryToken(Note), + useValue: {}, + }, + { + provide: getRepositoryToken(Tag), + useValue: {}, + }, + { + provide: getRepositoryToken(Alias), + useValue: {}, + }, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], + imports: [ + RevisionsModule, + UsersModule, + GroupsModule, + LoggerModule, + PermissionsModule, + HistoryModule, + MediaModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, mediaConfigMock], + }), + TypeOrmModule.forRoot(), + ], + }) + .overrideProvider(getConnectionToken()) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Edit)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Tag)) + .useValue({}) + .overrideProvider(getRepositoryToken(HistoryEntry)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteGroupPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteUserPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(Group)) + .useValue({}) + .overrideProvider(getRepositoryToken(MediaUpload)) + .useValue({}) + .overrideProvider(getRepositoryToken(Alias)) + .useValue({}) + .overrideProvider(getRepositoryToken(Session)) + .useValue({}) + .overrideProvider(getRepositoryToken(Author)) + .useValue({}) + .compile(); + + controller = module.get(AliasController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/alias/alias.controller.ts b/src/api/private/alias/alias.controller.ts new file mode 100644 index 000000000..a359728e0 --- /dev/null +++ b/src/api/private/alias/alias.controller.ts @@ -0,0 +1,140 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + BadRequestException, + Body, + Controller, + Delete, + HttpCode, + NotFoundException, + Param, + Post, + Put, + Req, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; + +import { + AlreadyInDBError, + ForbiddenIdError, + NotInDBError, + PrimaryAliasDeletionForbiddenError, +} from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { AliasCreateDto } from '../../../notes/alias-create.dto'; +import { AliasUpdateDto } from '../../../notes/alias-update.dto'; +import { AliasDto } from '../../../notes/alias.dto'; +import { AliasService } from '../../../notes/alias.service'; +import { NotesService } from '../../../notes/notes.service'; +import { PermissionsService } from '../../../permissions/permissions.service'; +import { UsersService } from '../../../users/users.service'; + +@Controller('alias') +export class AliasController { + constructor( + private readonly logger: ConsoleLoggerService, + private aliasService: AliasService, + private noteService: NotesService, + private userService: UsersService, + private permissionsService: PermissionsService, + ) { + this.logger.setContext(AliasController.name); + } + + @Post() + async addAlias( + @Req() req: Request, + @Body() newAliasDto: AliasCreateDto, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias( + newAliasDto.noteIdOrAlias, + ); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const updatedAlias = await this.aliasService.addAlias( + note, + newAliasDto.newAlias, + ); + return this.aliasService.toAliasDto(updatedAlias, note); + } catch (e) { + if (e instanceof AlreadyInDBError) { + throw new BadRequestException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Put(':alias') + async makeAliasPrimary( + @Req() req: Request, + @Param('alias') alias: string, + @Body() changeAliasDto: AliasUpdateDto, + ): Promise { + if (!changeAliasDto.primaryAlias) { + throw new BadRequestException( + `The field 'primaryAlias' must be set to 'true'.`, + ); + } + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(alias); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const updatedAlias = await this.aliasService.makeAliasPrimary( + note, + alias, + ); + return this.aliasService.toAliasDto(updatedAlias, note); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @Delete(':alias') + @HttpCode(204) + async removeAlias( + @Req() req: Request, + @Param('alias') alias: string, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const note = await this.noteService.getNoteByIdOrAlias(alias); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + await this.aliasService.removeAlias(note, alias); + return; + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof PrimaryAliasDeletionForbiddenError) { + throw new BadRequestException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } +} diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index 625cfbe13..9b8b2cb9d 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -15,6 +15,7 @@ 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 { AuthController } from './auth/auth.controller'; import { ConfigController } from './config/config.controller'; import { HistoryController } from './me/history/history.controller'; @@ -43,6 +44,7 @@ import { TokensController } from './tokens/tokens.controller'; HistoryController, MeController, NotesController, + AliasController, AuthController, ], }) diff --git a/src/api/public/alias/alias.controller.spec.ts b/src/api/public/alias/alias.controller.spec.ts new file mode 100644 index 000000000..c272c4298 --- /dev/null +++ b/src/api/public/alias/alias.controller.spec.ts @@ -0,0 +1,123 @@ +/* + * SPDX-FileCopyrightText: 2021 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 { + getConnectionToken, + getRepositoryToken, + TypeOrmModule, +} from '@nestjs/typeorm'; + +import { AuthToken } from '../../../auth/auth-token.entity'; +import { Author } from '../../../authors/author.entity'; +import appConfigMock from '../../../config/mock/app.config.mock'; +import mediaConfigMock from '../../../config/mock/media.config.mock'; +import { Group } from '../../../groups/group.entity'; +import { GroupsModule } from '../../../groups/groups.module'; +import { HistoryEntry } from '../../../history/history-entry.entity'; +import { HistoryModule } from '../../../history/history.module'; +import { Identity } from '../../../identity/identity.entity'; +import { LoggerModule } from '../../../logger/logger.module'; +import { MediaUpload } from '../../../media/media-upload.entity'; +import { MediaModule } from '../../../media/media.module'; +import { Alias } from '../../../notes/alias.entity'; +import { AliasService } from '../../../notes/alias.service'; +import { Note } from '../../../notes/note.entity'; +import { NotesService } from '../../../notes/notes.service'; +import { Tag } from '../../../notes/tag.entity'; +import { NoteGroupPermission } from '../../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../../permissions/note-user-permission.entity'; +import { PermissionsModule } from '../../../permissions/permissions.module'; +import { Edit } from '../../../revisions/edit.entity'; +import { Revision } from '../../../revisions/revision.entity'; +import { RevisionsModule } from '../../../revisions/revisions.module'; +import { Session } from '../../../users/session.entity'; +import { User } from '../../../users/user.entity'; +import { UsersModule } from '../../../users/users.module'; +import { AliasController } from './alias.controller'; + +describe('AliasController', () => { + let controller: AliasController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AliasController], + providers: [ + AliasService, + NotesService, + { + provide: getRepositoryToken(Note), + useValue: {}, + }, + { + provide: getRepositoryToken(Tag), + useValue: {}, + }, + { + provide: getRepositoryToken(Alias), + useValue: {}, + }, + { + provide: getRepositoryToken(User), + useValue: {}, + }, + ], + imports: [ + RevisionsModule, + UsersModule, + GroupsModule, + LoggerModule, + PermissionsModule, + HistoryModule, + MediaModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, mediaConfigMock], + }), + TypeOrmModule.forRoot(), + ], + }) + .overrideProvider(getConnectionToken()) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Edit)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Tag)) + .useValue({}) + .overrideProvider(getRepositoryToken(HistoryEntry)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteGroupPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteUserPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(Group)) + .useValue({}) + .overrideProvider(getRepositoryToken(MediaUpload)) + .useValue({}) + .overrideProvider(getRepositoryToken(Alias)) + .useValue({}) + .overrideProvider(getRepositoryToken(Session)) + .useValue({}) + .overrideProvider(getRepositoryToken(Author)) + .useValue({}) + .compile(); + + controller = module.get(AliasController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/public/alias/alias.controller.ts b/src/api/public/alias/alias.controller.ts new file mode 100644 index 000000000..35bf80676 --- /dev/null +++ b/src/api/public/alias/alias.controller.ts @@ -0,0 +1,159 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { + BadRequestException, + Body, + Controller, + Delete, + HttpCode, + NotFoundException, + Param, + Post, + Put, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { + ApiNoContentResponse, + ApiOkResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; + +import { TokenAuthGuard } from '../../../auth/token.strategy'; +import { + AlreadyInDBError, + ForbiddenIdError, + NotInDBError, + PrimaryAliasDeletionForbiddenError, +} from '../../../errors/errors'; +import { ConsoleLoggerService } from '../../../logger/console-logger.service'; +import { AliasCreateDto } from '../../../notes/alias-create.dto'; +import { AliasUpdateDto } from '../../../notes/alias-update.dto'; +import { AliasDto } from '../../../notes/alias.dto'; +import { AliasService } from '../../../notes/alias.service'; +import { NotesService } from '../../../notes/notes.service'; +import { PermissionsService } from '../../../permissions/permissions.service'; +import { User } from '../../../users/user.entity'; +import { FullApi } from '../../utils/fullapi-decorator'; +import { RequestUser } from '../../utils/request-user.decorator'; + +@ApiTags('alias') +@ApiSecurity('token') +@Controller('alias') +export class AliasController { + constructor( + private readonly logger: ConsoleLoggerService, + private aliasService: AliasService, + private noteService: NotesService, + private permissionsService: PermissionsService, + ) { + this.logger.setContext(AliasController.name); + } + + @UseGuards(TokenAuthGuard) + @Post() + @ApiOkResponse({ + description: 'The new alias', + type: AliasDto, + }) + @FullApi + async addAlias( + @RequestUser() user: User, + @Body() newAliasDto: AliasCreateDto, + ): Promise { + try { + const note = await this.noteService.getNoteByIdOrAlias( + newAliasDto.noteIdOrAlias, + ); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const updatedAlias = await this.aliasService.addAlias( + note, + newAliasDto.newAlias, + ); + return this.aliasService.toAliasDto(updatedAlias, note); + } catch (e) { + if (e instanceof AlreadyInDBError) { + throw new BadRequestException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @UseGuards(TokenAuthGuard) + @Put(':alias') + @ApiOkResponse({ + description: 'The updated alias', + type: AliasDto, + }) + @FullApi + async makeAliasPrimary( + @RequestUser() user: User, + @Param('alias') alias: string, + @Body() changeAliasDto: AliasUpdateDto, + ): Promise { + if (!changeAliasDto.primaryAlias) { + throw new BadRequestException( + `The field 'primaryAlias' must be set to 'true'.`, + ); + } + try { + const note = await this.noteService.getNoteByIdOrAlias(alias); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + const updatedAlias = await this.aliasService.makeAliasPrimary( + note, + alias, + ); + return this.aliasService.toAliasDto(updatedAlias, note); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } + + @UseGuards(TokenAuthGuard) + @Delete(':alias') + @HttpCode(204) + @ApiNoContentResponse({ + description: 'The alias was deleted', + }) + @FullApi + async removeAlias( + @RequestUser() user: User, + @Param('alias') alias: string, + ): Promise { + try { + const note = await this.noteService.getNoteByIdOrAlias(alias); + if (!this.permissionsService.isOwner(user, note)) { + throw new UnauthorizedException('Reading note denied!'); + } + await this.aliasService.removeAlias(note, alias); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + if (e instanceof PrimaryAliasDeletionForbiddenError) { + throw new BadRequestException(e.message); + } + if (e instanceof ForbiddenIdError) { + throw new BadRequestException(e.message); + } + throw e; + } + } +} diff --git a/src/api/public/public-api.module.ts b/src/api/public/public-api.module.ts index bafbaa794..8818bcca3 100644 --- a/src/api/public/public-api.module.ts +++ b/src/api/public/public-api.module.ts @@ -13,6 +13,7 @@ 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 { MeController } from './me/me.controller'; import { MediaController } from './media/media.controller'; import { MonitoringController } from './monitoring/monitoring.controller'; @@ -30,6 +31,7 @@ import { NotesController } from './notes/notes.controller'; PermissionsModule, ], controllers: [ + AliasController, MeController, NotesController, MediaController,