Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: better permission system #267

Merged
merged 12 commits into from
Sep 7, 2024
4 changes: 2 additions & 2 deletions jest.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"!src/*/**/*.dto.ts",
"!src/*/**/*.es-doc.ts",
"!src/*/**/*.enum.ts",
"!src/*/**/*.deprecated.entity.ts",
"!src/common/config/configuration.ts"
"!src/common/config/configuration.ts",
"!src/auth/definitions.ts"
],
"testTimeout": 60000
}
135 changes: 51 additions & 84 deletions src/answer/answer.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@ import {
Post,
Put,
Query,
UseFilters,
UseInterceptors,
} from '@nestjs/common';
import { AttitudeTypeDto } from '../attitude/DTO/attitude.dto';
import { UpdateAttitudeResponseDto } from '../attitude/DTO/update-attitude.dto';
import { AuthService, AuthorizedAction } from '../auth/auth.service';
import { AuthService } from '../auth/auth.service';
import {
AuthToken,
CurrentUserOwnResource,
Guard,
ResourceId,
ResourceOwnerIdGetter,
} from '../auth/guard.decorator';
import { UserId } from '../auth/user-id.decorator';
import { BaseResponseDto } from '../common/DTO/base-response.dto';
import { PageDto } from '../common/DTO/page.dto';
import { BaseErrorExceptionFilter } from '../common/error/error-filter';
import { TokenValidateInterceptor } from '../common/interceptor/token-validate.interceptor';
import { QuestionsService } from '../questions/questions.service';
import { CreateAnswerResponseDto } from './DTO/create-answer.dto';
import { GetAnswerDetailResponseDto } from './DTO/get-answer-detail.dto';
Expand All @@ -28,31 +32,34 @@ import { UpdateAnswerRequestDto } from './DTO/update-answer.dto';
import { AnswerService } from './answer.service';

@Controller('/questions/:question_id/answers')
@UseFilters(BaseErrorExceptionFilter)
@UseInterceptors(TokenValidateInterceptor)
export class AnswerController {
constructor(
private readonly authService: AuthService,
private readonly answerService: AnswerService,
private readonly questionsService: QuestionsService,
) {}

@ResourceOwnerIdGetter('answer')
async getAnswerOwner(answerId: number): Promise<number | undefined> {
return this.answerService.getCreatedByIdAcrossQuestions(answerId);
}

@ResourceOwnerIdGetter('question')
async getQuestionOwner(questionId: number): Promise<number | undefined> {
return this.questionsService.getQuestionCreatedById(questionId);
}

@Get('/')
@Guard('enumerate-answers', 'question')
async getQuestionAnswers(
@Param('question_id') questionId: number,
@Param('question_id', ParseIntPipe) @ResourceId() questionId: number,
@Query()
{ page_start: pageStart, page_size: pageSize }: PageDto,
@Headers('Authorization') auth: string | undefined,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId() userId: number | undefined,
@Ip() ip: string,
@Headers('User-Agent') userAgent: string,
): Promise<GetAnswersResponseDto> {
let userId: number | undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
userId = this.authService.verify(auth).userId;
} catch {
// The user is not logged in.
}
const [answers, page] = await this.answerService.getQuestionAnswers(
questionId,
pageStart,
Expand All @@ -72,19 +79,14 @@ export class AnswerController {
}

@Post('/')
@Guard('create', 'answer')
@CurrentUserOwnResource()
async answerQuestion(
@Param('question_id', ParseIntPipe) questionId: number,
@Body('content') content: string,
@Headers('Authorization') auth: string | undefined,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<CreateAnswerResponseDto> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.create,
userId,
'answer',
undefined,
);
const answerId = await this.answerService.createAnswer(
questionId,
userId,
Expand All @@ -100,20 +102,15 @@ export class AnswerController {
}

@Get('/:answer_id')
@Guard('query', 'answer')
async getAnswerDetail(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Headers('Authorization') auth: string | undefined,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId() userId: number | undefined,
@Ip() ip: string,
@Headers('User-Agent') userAgent: string,
): Promise<GetAnswerDetailResponseDto> {
let userId;
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
userId = this.authService.verify(auth).userId;
} catch {
// The user is not logged in.
}
const answerDto = await this.answerService.getAnswerDto(
questionId,
answerId,
Expand All @@ -138,20 +135,14 @@ export class AnswerController {
}

@Put('/:answer_id')
@Guard('modify', 'answer')
async updateAnswer(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Body() { content }: UpdateAnswerRequestDto,
@Headers('Authorization') auth: string | undefined,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<BaseResponseDto> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.modify,
await this.answerService.getCreatedById(questionId, answerId),
'answer',
questionId,
);
await this.answerService.updateAnswer(
questionId,
answerId,
Expand All @@ -165,37 +156,25 @@ export class AnswerController {
}

@Delete('/:answer_id')
@Guard('delete', 'answer')
async deleteAnswer(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Headers('Authorization') auth: string | undefined,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<void> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.delete,
await this.answerService.getCreatedById(questionId, answerId),
'answer',
answerId,
);
await this.answerService.deleteAnswer(questionId, answerId, userId);
}

@Post('/:answer_id/attitudes')
@Guard('attitude', 'answer')
async updateAttitudeToAnswer(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Body() { attitude_type: attitudeType }: AttitudeTypeDto,
@Headers('Authorization') auth: string | undefined,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<UpdateAttitudeResponseDto> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.other,
await this.answerService.getCreatedById(questionId, answerId),
'answer/attitude',
answerId,
);
const attitudes = await this.answerService.setAttitudeToAnswer(
questionId,
answerId,
Expand All @@ -212,19 +191,13 @@ export class AnswerController {
}

@Put('/:answer_id/favorite')
@Guard('favorite', 'answer')
async favoriteAnswer(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Headers('Authorization') auth: string | undefined,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<BaseResponseDto> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.other,
await this.answerService.getCreatedById(questionId, answerId),
'answer/favourite',
answerId,
);
await this.answerService.favoriteAnswer(questionId, answerId, userId);
return {
code: 200,
Expand All @@ -233,19 +206,13 @@ export class AnswerController {
}

@Delete('/:answer_id/favorite')
@Guard('unfavorite', 'answer')
async unfavoriteAnswer(
@Param('question_id', ParseIntPipe) questionId: number,
@Param('answer_id', ParseIntPipe) answerId: number,
@Headers('Authorization') auth: string | undefined,
@Param('answer_id', ParseIntPipe) @ResourceId() answerId: number,
@Headers('Authorization') @AuthToken() auth: string | undefined,
@UserId(true) userId: number,
): Promise<void> {
const userId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.other,
await this.answerService.getCreatedById(questionId, answerId),
'answer/favourite',
answerId,
);
await this.answerService.unfavoriteAnswer(questionId, answerId, userId);
}
}
12 changes: 12 additions & 0 deletions src/answer/answer.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,18 @@ export class AnswerService {
return answer.createdById;
}

async getCreatedByIdAcrossQuestions(answerId: number): Promise<number> {
const answer = await this.prismaService.answer.findUnique({
where: {
id: answerId,
},
});
if (!answer) {
throw new AnswerNotFoundError(answerId);
}
return answer.createdById;
}

async countQuestionAnswers(questionId: number): Promise<number> {
if ((await this.questionsService.isQuestionExists(questionId)) == false)
throw new QuestionNotFoundError(questionId);
Expand Down
21 changes: 18 additions & 3 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { Module, ValidationPipe } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_PIPE } from '@nestjs/core';
import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
import { AnswerModule } from './answer/answer.module';
import { AttachmentsModule } from './attachments/attachments.module';
import { AvatarsModule } from './avatars/avatars.module';
import { CommentsModule } from './comments/comment.module';
import configuration from './common/config/configuration';
import { BaseErrorExceptionFilter } from './common/error/error-filter';
import { EnsureGuardInterceptor } from './common/interceptor/ensure-guard.interceptor';
import { TokenValidateInterceptor } from './common/interceptor/token-validate.interceptor';
import { GroupsModule } from './groups/groups.module';
import { MaterialbundlesModule } from './materialbundles/materialbundles.module';
import { MaterialsModule } from './materials/materials.module';
import { QuestionsModule } from './questions/questions.module';
import { UsersModule } from './users/users.module';
import { AttachmentsModule } from './attachments/attachments.module';
import { MaterialbundlesModule } from './materialbundles/materialbundles.module';
@Module({
imports: [
ConfigModule.forRoot({ load: [configuration] }),
Expand All @@ -38,6 +41,18 @@ import { MaterialbundlesModule } from './materialbundles/materialbundles.module'
disableErrorMessages: false,
}),
},
{
provide: APP_FILTER,
useClass: BaseErrorExceptionFilter,
},
{
provide: APP_INTERCEPTOR,
useClass: TokenValidateInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: EnsureGuardInterceptor,
},
],
})
export class AppModule {}
33 changes: 14 additions & 19 deletions src/attachments/attachments.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,25 @@ import {
Body,
Controller,
Get,
Headers,
Param,
ParseIntPipe,
Post,
Headers,
UsePipes,
ValidationPipe,
UploadedFile,
UseFilters,
UseInterceptors,
UploadedFile,
ParseIntPipe,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { AttachmentsService } from './attachments.service';
import { attachmentTypeDto } from './DTO/attachments.dto';
import { FileInterceptor } from '@nestjs/platform-express';
import { AuthService } from '../auth/auth.service';
import { BaseErrorExceptionFilter } from '../common/error/error-filter';
import { AuthorizedAction, AuthService } from '../auth/auth.service';
import { attachmentTypeDto } from './DTO/attachments.dto';
import { getAttachmentResponseDto } from './DTO/get-attachment.dto';
import { uploadAttachmentDto } from './DTO/upload-attachment.dto';
@UsePipes(new ValidationPipe())
@UseFilters(new BaseErrorExceptionFilter())
import { AttachmentsService } from './attachments.service';
import { AuthToken, Guard } from '../auth/guard.decorator';

@Controller('attachments')
export class AttachmentsController {
constructor(
Expand All @@ -39,19 +39,12 @@ export class AttachmentsController {

@Post()
@UseInterceptors(FileInterceptor('file'))
@Guard('create', 'attachment')
async uploadAttachment(
@Body() { type }: attachmentTypeDto,
@UploadedFile() file: Express.Multer.File,
@Headers('Authorization') auth: string | undefined,
@Headers('Authorization') @AuthToken() auth: string | undefined,
): Promise<uploadAttachmentDto> {
const uploaderId = this.authService.verify(auth).userId;
this.authService.audit(
auth,
AuthorizedAction.create,
uploaderId,
'attachment',
undefined,
);
const attachmentId = await this.attachmentsService.uploadAttachment(
type,
file,
Expand All @@ -66,8 +59,10 @@ export class AttachmentsController {
}

@Get('/:attachmentId')
@Guard('query', 'attachment')
async getAttachmentDetail(
@Param('attachmentId', ParseIntPipe) id: number,
@Headers('Authorization') @AuthToken() auth: string | undefined,
): Promise<getAttachmentResponseDto> {
const attachment = await this.attachmentsService.getAttachment(id);
return {
Expand Down
Loading