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

(feature) Support for creating / updating / deleting projects via api key #195

Merged
merged 1 commit into from
Oct 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/production/src/auth/decorators/auth.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
6 changes: 3 additions & 3 deletions apps/production/src/auth/guards/api-key-rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}

Expand Down
32 changes: 31 additions & 1 deletion apps/production/src/project/dto/create-project.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
21 changes: 6 additions & 15 deletions apps/production/src/project/dto/update-project.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}
94 changes: 73 additions & 21 deletions apps/production/src/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
Patch,
ConflictException,
Res,
UnauthorizedException,
} from '@nestjs/common'
import { Response } from 'express'
import { ApiTags, ApiQuery, ApiResponse } from '@nestjs/swagger'
Expand All @@ -28,6 +29,7 @@
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'
Expand All @@ -44,7 +46,7 @@
deleteProjectRedis,
generateProjectId,
} from './project.service'
import { UserType, ACCOUNT_PLANS, PlanCode } from '../user/entities/user.entity'

Check warning on line 49 in apps/production/src/project/project.controller.ts

View workflow job for this annotation

GitHub Actions / checks (16.19.x)

'ACCOUNT_PLANS' is defined but never used
import { ActionTokenType } from '../action-tokens/action-token.entity'
import { ActionTokensService } from '../action-tokens/action-tokens.service'
import { MailerService } from '../mailer/mailer.service'
Expand Down Expand Up @@ -97,7 +99,7 @@
}

@ApiTags('Project')
@Controller('project')
@Controller(['project', 'v1/project'])
export class ProjectController {
constructor(
private readonly projectService: ProjectService,
Expand Down Expand Up @@ -331,14 +333,17 @@

@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<Project> {
this.logger.log({ projectDTO, userId }, 'POST /project')

if (!userId) {
throw new UnauthorizedException('Please auth first')
}

const user = await this.userService.findOneWithRelations(userId, [
'projects',
])
Expand Down Expand Up @@ -406,12 +411,35 @@
)
}

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)
Expand Down Expand Up @@ -1187,14 +1215,18 @@

@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<any> {
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',
Expand Down Expand Up @@ -1366,8 +1398,7 @@

@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,
Expand All @@ -1378,6 +1409,11 @@
{ ..._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'],
Expand All @@ -1390,28 +1426,44 @@

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']))

// await updateProjectRedis(id, project)
await deleteProjectRedis(id)

return project
return _omit(project, ['admin', 'passwordHash', 'share'])
}

// The routes related to sharing projects feature
Expand Down
55 changes: 38 additions & 17 deletions apps/production/src/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -578,40 +583,56 @@ 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`)
}
})
}

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<number | null> {
const countKey = getRedisUserCountKey(uid)
Expand Down
Loading
Loading