From 175a71dee79d734fd2837a5a2f2b2e2ea900aa3d Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 7 Oct 2023 11:49:56 +0100 Subject: [PATCH] added support for creating / updating / deleting projects via api key --- .../src/auth/decorators/auth.decorator.ts | 4 +- .../auth/guards/api-key-rate-limit.guard.ts | 6 +- .../src/project/dto/create-project.dto.ts | 32 ++++++- .../src/project/dto/update-project.dto.ts | 21 ++--- .../src/project/project.controller.ts | 94 ++++++++++++++----- .../production/src/project/project.service.ts | 55 +++++++---- .../src/user/entities/user.entity.ts | 14 +-- migrations/mysql/2023_10_07.sql | 1 + 8 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 migrations/mysql/2023_10_07.sql diff --git a/apps/production/src/auth/decorators/auth.decorator.ts b/apps/production/src/auth/decorators/auth.decorator.ts index f7f8185f1..727ea2e76 100644 --- a/apps/production/src/auth/decorators/auth.decorator.ts +++ b/apps/production/src/auth/decorators/auth.decorator.ts @@ -4,8 +4,8 @@ import { JwtAccessTokenGuard, MultiAuthGuard, RolesGuard, -} from 'src/auth/guards' -import { UserType } from 'src/user/entities/user.entity' +} from '../guards' +import { UserType } from '../../user/entities/user.entity' import { ROLES_KEY } from './roles.decorator' export const IS_OPTIONAL_AUTH_KEY = 'isOptionalAuth' diff --git a/apps/production/src/auth/guards/api-key-rate-limit.guard.ts b/apps/production/src/auth/guards/api-key-rate-limit.guard.ts index 8ce458501..97b243ae2 100644 --- a/apps/production/src/auth/guards/api-key-rate-limit.guard.ts +++ b/apps/production/src/auth/guards/api-key-rate-limit.guard.ts @@ -1,6 +1,6 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common' -import { checkRateLimitForApiKey } from 'src/common/utils' -import { ACCOUNT_PLANS, PlanCode } from 'src/user/entities/user.entity' +import { checkRateLimitForApiKey } from '../../common/utils' +import { PlanCode } from '../../user/entities/user.entity' @Injectable() export class ApiKeyRateLimitGuard implements CanActivate { @@ -12,7 +12,7 @@ export class ApiKeyRateLimitGuard implements CanActivate { if (!user) return false const reqAmount = - ACCOUNT_PLANS[user.planCode as PlanCode].maxApiKeyRequestsPerHour + user.planCode === PlanCode.none ? 0 : user.maxApiKeyRequestsPerHour return checkRateLimitForApiKey(user.apiKey, reqAmount) } diff --git a/apps/production/src/project/dto/create-project.dto.ts b/apps/production/src/project/dto/create-project.dto.ts index 2d4e444ea..24ba5b2ef 100644 --- a/apps/production/src/project/dto/create-project.dto.ts +++ b/apps/production/src/project/dto/create-project.dto.ts @@ -14,5 +14,35 @@ export class CreateProjectDTO { @ApiProperty({ required: false, }) - isCaptcha: boolean + isCaptcha?: boolean + + @ApiProperty({ + required: false, + }) + public?: boolean + + @ApiProperty({ + required: false, + }) + active?: boolean + + @ApiProperty({ + required: false, + }) + isPasswordProtected?: boolean + + @ApiProperty({ + required: false, + }) + password?: string + + @ApiProperty({ + required: false, + }) + origins?: string[] + + @ApiProperty({ + required: false, + }) + ipBlacklist?: string[] } diff --git a/apps/production/src/project/dto/update-project.dto.ts b/apps/production/src/project/dto/update-project.dto.ts index da20d1051..a6dbcde0c 100644 --- a/apps/production/src/project/dto/update-project.dto.ts +++ b/apps/production/src/project/dto/update-project.dto.ts @@ -1,32 +1,23 @@ -import { IntersectionType, PickType } from '@nestjs/mapped-types' -import { IsNotEmpty } from 'class-validator' +import { IntersectionType, PartialType } from '@nestjs/mapped-types' import { ApiProperty } from '@nestjs/swagger' import { ProjectDTO } from './project.dto' import { ProjectPasswordDto } from './project-password.dto' export class UpdateProjectDto extends IntersectionType( - PickType(ProjectDTO, [ - 'id', - 'name', - 'origins', - 'ipBlacklist', - 'isCaptcha', - ] as const), + PartialType(ProjectDTO), ProjectPasswordDto, ) { @ApiProperty({ - required: true, + required: false, description: "The project's state. If enabled - all the incoming analytics data will be saved.", }) - @IsNotEmpty() - active: boolean + active?: boolean @ApiProperty({ - required: true, + required: false, description: "When true, anyone on the internet (including Google) would be able to see the project's Dashboard.", }) - @IsNotEmpty() - public: boolean + public?: boolean } diff --git a/apps/production/src/project/project.controller.ts b/apps/production/src/project/project.controller.ts index d550f3577..2c934d13b 100644 --- a/apps/production/src/project/project.controller.ts +++ b/apps/production/src/project/project.controller.ts @@ -19,6 +19,7 @@ import { Patch, ConflictException, Res, + UnauthorizedException, } from '@nestjs/common' import { Response } from 'express' import { ApiTags, ApiQuery, ApiResponse } from '@nestjs/swagger' @@ -28,6 +29,7 @@ import * as _map from 'lodash/map' import * as _trim from 'lodash/trim' import * as _size from 'lodash/size' import * as _includes from 'lodash/includes' +import * as _isBoolean from 'lodash/isBoolean' import * as _omit from 'lodash/omit' import * as _split from 'lodash/split' import * as _head from 'lodash/head' @@ -97,7 +99,7 @@ const isValidUpdateShareDTO = (share: ShareUpdateDTO): boolean => { } @ApiTags('Project') -@Controller('project') +@Controller(['project', 'v1/project']) export class ProjectController { constructor( private readonly projectService: ProjectService, @@ -331,14 +333,17 @@ export class ProjectController { @Post('/') @ApiResponse({ status: 201, type: Project }) - @UseGuards(JwtAccessTokenGuard, RolesGuard) - @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) async create( @Body() projectDTO: CreateProjectDTO, @CurrentUserId() userId: string, ): Promise { this.logger.log({ projectDTO, userId }, 'POST /project') + if (!userId) { + throw new UnauthorizedException('Please auth first') + } + const user = await this.userService.findOneWithRelations(userId, [ 'projects', ]) @@ -406,12 +411,35 @@ export class ProjectController { ) } + if (projectDTO.isPasswordProtected && projectDTO.password) { + project.isPasswordProtected = true + project.passwordHash = await hash(projectDTO.password, 10) + } + + if (projectDTO.public) { + project.public = Boolean(projectDTO.public) + } + + if (projectDTO.active) { + project.active = Boolean(projectDTO.active) + } + + if (projectDTO.origins) { + this.projectService.validateOrigins(projectDTO) + project.origins = projectDTO.origins + } + + if (projectDTO.ipBlacklist) { + this.projectService.validateIPBlacklist(projectDTO) + project.ipBlacklist = projectDTO.ipBlacklist + } + const newProject = await this.projectService.create(project) user.projects.push(project) await this.userService.create(user) - return newProject + return _omit(newProject, ['passwordHash']) } catch (reason) { console.error('[ERROR] Failed to create a new project:') console.error(reason) @@ -1187,14 +1215,18 @@ export class ProjectController { @Delete('/:id') @HttpCode(204) - @UseGuards(JwtAccessTokenGuard, RolesGuard) - @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) @ApiResponse({ status: 204, description: 'Empty body' }) async delete( @Param('id') id: string, @CurrentUserId() uid: string, ): Promise { this.logger.log({ uid, id }, 'DELETE /project/:id') + + if (!uid) { + throw new UnauthorizedException('Please auth first') + } + if (!isValidPID(id)) { throw new BadRequestException( 'The provided Project ID (pid) is incorrect', @@ -1366,8 +1398,7 @@ export class ProjectController { @Put('/:id') @HttpCode(200) - @UseGuards(JwtAccessTokenGuard, RolesGuard) - @Roles(UserType.CUSTOMER, UserType.ADMIN) + @Auth([], true) @ApiResponse({ status: 200, type: Project }) async update( @Param('id') id: string, @@ -1378,6 +1409,11 @@ export class ProjectController { { ..._omit(projectDTO, ['password']), uid, id }, 'PUT /project/:id', ) + + if (!uid) { + throw new UnauthorizedException('Please auth first') + } + this.projectService.validateProject(projectDTO) const project = await this.projectService.findOne(id, { relations: ['admin', 'share', 'share.user'], @@ -1390,20 +1426,36 @@ export class ProjectController { this.projectService.allowedToManage(project, uid, user.roles) - project.active = projectDTO.active - project.origins = _map(projectDTO.origins, _trim) - project.ipBlacklist = _map(projectDTO.ipBlacklist, _trim) - project.name = _trim(projectDTO.name) - project.public = projectDTO.public + if (projectDTO.public) { + project.public = Boolean(projectDTO.public) + } - if (projectDTO.isPasswordProtected) { - if (projectDTO.password) { - project.isPasswordProtected = true - project.passwordHash = await hash(projectDTO.password, 10) + if (projectDTO.active) { + project.active = Boolean(projectDTO.active) + } + + if (projectDTO.origins) { + project.origins = _map(projectDTO.origins, _trim) + } + + if (projectDTO.ipBlacklist) { + project.ipBlacklist = _map(projectDTO.ipBlacklist, _trim) + } + + if (projectDTO.name) { + project.name = _trim(projectDTO.name) + } + + if (_isBoolean(projectDTO.isPasswordProtected)) { + if (projectDTO.isPasswordProtected) { + if (projectDTO.password) { + project.isPasswordProtected = true + project.passwordHash = await hash(projectDTO.password, 10) + } + } else { + project.isPasswordProtected = false + project.passwordHash = null } - } else { - project.isPasswordProtected = false - project.passwordHash = null } await this.projectService.update(id, _omit(project, ['share', 'admin'])) @@ -1411,7 +1463,7 @@ export class ProjectController { // await updateProjectRedis(id, project) await deleteProjectRedis(id) - return project + return _omit(project, ['admin', 'passwordHash', 'share']) } // The routes related to sharing projects feature diff --git a/apps/production/src/project/project.service.ts b/apps/production/src/project/project.service.ts index 332bc28e7..bf61a4303 100644 --- a/apps/production/src/project/project.service.ts +++ b/apps/production/src/project/project.service.ts @@ -54,7 +54,12 @@ import { import { IUsageInfoRedis } from '../user/interfaces' import { ProjectSubscriber } from './entity' import { AddSubscriberType } from './types' -import { GetSubscribersQueriesDto, UpdateSubscriberBodyDto } from './dto' +import { + CreateProjectDTO, + GetSubscribersQueriesDto, + UpdateProjectDto, + UpdateSubscriberBodyDto, +} from './dto' import { ReportFrequency } from './enums' import { nFormatter } from '../common/utils' @@ -578,33 +583,28 @@ export class ProjectService { ]) } - validateProject(projectDTO: ProjectDTO, creatingProject = false) { - if (_size(projectDTO.name) > 50) - throw new UnprocessableEntityException('The project name is too long') - - if (creatingProject) { - return - } - - if (!isValidPID(projectDTO.id)) - throw new UnprocessableEntityException( - 'The provided Project ID (pid) is incorrect', - ) + validateOrigins( + projectDTO: ProjectDTO | UpdateProjectDto | CreateProjectDTO, + ) { if (_size(_join(projectDTO.origins, ',')) > 300) throw new UnprocessableEntityException( 'The list of allowed origins has to be smaller than 300 symbols', ) - if (_size(_join(projectDTO.ipBlacklist, ',')) > 300) - throw new UnprocessableEntityException( - 'The list of allowed blacklisted IP addresses must be less than 300 characters.', - ) _map(projectDTO.origins, host => { if (!ORIGINS_REGEX.test(_trim(host))) { throw new ConflictException(`Host ${host} is not correct`) } }) + } + validateIPBlacklist( + projectDTO: ProjectDTO | UpdateProjectDto | CreateProjectDTO, + ) { + if (_size(_join(projectDTO.ipBlacklist, ',')) > 300) + throw new UnprocessableEntityException( + 'The list of allowed blacklisted IP addresses must be less than 300 characters.', + ) _map(projectDTO.ipBlacklist, ip => { if (!net.isIP(_trim(ip)) && !IP_REGEX.test(_trim(ip))) { throw new ConflictException(`IP address ${ip} is not correct`) @@ -612,6 +612,27 @@ export class ProjectService { }) } + validateProject( + projectDTO: ProjectDTO | UpdateProjectDto | CreateProjectDTO, + creatingProject = false, + ) { + if (_size(projectDTO.name) > 50) + throw new UnprocessableEntityException('The project name is too long') + + if (creatingProject) { + return + } + + // @ts-ignore + if (projectDTO?.id && !isValidPID(projectDTO.id)) + throw new UnprocessableEntityException( + 'The provided Project ID (pid) is incorrect', + ) + + this.validateOrigins(projectDTO) + this.validateIPBlacklist(projectDTO) + } + // Returns amount of existing events starting from month async getRedisCount(uid: string): Promise { const countKey = getRedisUserCountKey(uid) diff --git a/apps/production/src/user/entities/user.entity.ts b/apps/production/src/user/entities/user.entity.ts index 43b9d288f..7c4895314 100644 --- a/apps/production/src/user/entities/user.entity.ts +++ b/apps/production/src/user/entities/user.entity.ts @@ -35,21 +35,18 @@ export const ACCOUNT_PLANS = { id: PlanCode.none, monthlyUsageLimit: 0, maxAlerts: 0, - maxApiKeyRequestsPerHour: 0, legacy: false, }, [PlanCode.free]: { id: PlanCode.free, monthlyUsageLimit: 5000, maxAlerts: 1, - maxApiKeyRequestsPerHour: 600, legacy: true, }, [PlanCode.trial]: { id: PlanCode.trial, monthlyUsageLimit: 100000, maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode.hobby]: { @@ -58,7 +55,6 @@ export const ACCOUNT_PLANS = { pid: '813694', // Plan ID ypid: '813695', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode.freelancer]: { @@ -67,7 +63,6 @@ export const ACCOUNT_PLANS = { pid: '752316', // Plan ID ypid: '776469', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode['200k']]: { @@ -76,7 +71,6 @@ export const ACCOUNT_PLANS = { pid: '854654', // Plan ID ypid: '854655', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode['500k']]: { @@ -85,7 +79,6 @@ export const ACCOUNT_PLANS = { pid: '854656', // Plan ID ypid: '854657', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode.startup]: { @@ -94,7 +87,6 @@ export const ACCOUNT_PLANS = { pid: '752317', ypid: '776470', maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode['2m']]: { @@ -103,7 +95,6 @@ export const ACCOUNT_PLANS = { pid: '854663', // Plan ID ypid: '854664', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode.enterprise]: { @@ -112,7 +103,6 @@ export const ACCOUNT_PLANS = { pid: '752318', ypid: '776471', maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, [PlanCode['10m']]: { @@ -121,7 +111,6 @@ export const ACCOUNT_PLANS = { pid: '854665', // Plan ID ypid: '854666', // Plan ID - Yearly billing maxAlerts: 50, - maxApiKeyRequestsPerHour: 600, legacy: false, }, } @@ -240,6 +229,9 @@ export class User { @Column('int', { default: 50 }) maxProjects: number + @Column('int', { default: 600 }) + maxApiKeyRequestsPerHour: number + @Column({ default: false }) isTwoFactorAuthenticationEnabled: boolean diff --git a/migrations/mysql/2023_10_07.sql b/migrations/mysql/2023_10_07.sql new file mode 100644 index 000000000..f96f848fe --- /dev/null +++ b/migrations/mysql/2023_10_07.sql @@ -0,0 +1 @@ +alter table user add column `maxApiKeyRequestsPerHour` int NOT NULL DEFAULT '600';