stop exposing internal ids and switch over to hashids
This commit is contained in:
parent
2d3589e13a
commit
fe3821ad42
@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
### Security
|
||||
|
||||
- start using hashids to prevent insights into form ids
|
||||
|
||||
## [1.0.0] - 2022-02-28
|
||||
|
||||
### Added
|
||||
|
||||
@ -51,6 +51,7 @@
|
||||
"graphql-subscriptions": "^2.0.0",
|
||||
"graphql-tools": "^8.2.0",
|
||||
"handlebars": "^4.7.7",
|
||||
"hashids": "^2.2.10",
|
||||
"html-to-text": "^8.1.0",
|
||||
"inquirer": "^8.2.0",
|
||||
"ioredis": "^4.28.5",
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { commands } from './command'
|
||||
import { guards } from './guard'
|
||||
import { pipes } from './pipe'
|
||||
import { resolvers } from './resolver'
|
||||
import { services } from './service'
|
||||
|
||||
export const providers = [
|
||||
...resolvers,
|
||||
...commands,
|
||||
...services,
|
||||
...guards,
|
||||
...pipes,
|
||||
...resolvers,
|
||||
...services,
|
||||
]
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql'
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql'
|
||||
|
||||
@ObjectType('Deleted')
|
||||
export class DeletedModel {
|
||||
@Field()
|
||||
@Field(() => ID)
|
||||
id: string
|
||||
|
||||
constructor(id: string) {
|
||||
|
||||
@ -3,6 +3,8 @@ import { FormEntity } from '../../entity/form.entity'
|
||||
|
||||
@ObjectType('Form')
|
||||
export class FormModel {
|
||||
readonly _id: number
|
||||
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@ -24,8 +26,9 @@ export class FormModel {
|
||||
@Field()
|
||||
readonly anonymousSubmission: boolean
|
||||
|
||||
constructor(form: FormEntity) {
|
||||
this.id = form.id.toString()
|
||||
constructor(id: string, form: FormEntity) {
|
||||
this._id = form.id
|
||||
this.id = id
|
||||
this.title = form.title
|
||||
this.created = form.created
|
||||
this.lastModified = form.lastModified
|
||||
|
||||
@ -7,8 +7,8 @@ export class ProfileModel extends UserModel {
|
||||
@Field(() => [String])
|
||||
readonly roles: string[]
|
||||
|
||||
constructor(user: UserEntity) {
|
||||
super(user)
|
||||
constructor(id: string, user: UserEntity) {
|
||||
super(id, user)
|
||||
|
||||
this.roles = user.roles
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ import { SubmissionFieldEntity } from '../../entity/submission.field.entity'
|
||||
|
||||
@ObjectType('SubmissionField')
|
||||
export class SubmissionFieldModel {
|
||||
readonly _id: number
|
||||
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@ -12,8 +14,9 @@ export class SubmissionFieldModel {
|
||||
@Field()
|
||||
readonly type: string
|
||||
|
||||
constructor(field: SubmissionFieldEntity) {
|
||||
this.id = field.id.toString()
|
||||
constructor(id: string, field: SubmissionFieldEntity) {
|
||||
this._id = field.id
|
||||
this.id = id
|
||||
this.value = JSON.stringify(field.content)
|
||||
this.type = field.type
|
||||
}
|
||||
|
||||
@ -5,8 +5,10 @@ import { GeoLocationModel } from './geo.location.model'
|
||||
|
||||
@ObjectType('Submission')
|
||||
export class SubmissionModel {
|
||||
readonly _id: number
|
||||
|
||||
@Field(() => ID)
|
||||
readonly id: number
|
||||
readonly id: string
|
||||
|
||||
@Field()
|
||||
readonly ipAddr: string
|
||||
@ -29,8 +31,9 @@ export class SubmissionModel {
|
||||
@Field({ nullable: true })
|
||||
readonly lastModified?: Date
|
||||
|
||||
constructor(submission: SubmissionEntity) {
|
||||
this.id = submission.id
|
||||
constructor(id: string, submission: SubmissionEntity) {
|
||||
this._id = submission.id
|
||||
this.id = id
|
||||
|
||||
this.ipAddr = submission.ipAddr
|
||||
this.geoLocation = new GeoLocationModel(submission.geoLocation)
|
||||
|
||||
@ -3,6 +3,8 @@ import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
|
||||
@ObjectType('SubmissionProgress')
|
||||
export class SubmissionProgressModel {
|
||||
readonly _id: number
|
||||
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@ -18,8 +20,9 @@ export class SubmissionProgressModel {
|
||||
@Field({ nullable: true })
|
||||
readonly lastModified?: Date
|
||||
|
||||
constructor(submission: Partial<SubmissionEntity>) {
|
||||
this.id = submission.id.toString()
|
||||
constructor(id: string, submission: Partial<SubmissionEntity>) {
|
||||
this._id = submission.id
|
||||
this.id = id
|
||||
|
||||
this.timeElapsed = submission.timeElapsed
|
||||
this.percentageComplete = submission.percentageComplete
|
||||
|
||||
@ -3,6 +3,8 @@ import { UserEntity } from '../../entity/user.entity'
|
||||
|
||||
@ObjectType('User')
|
||||
export class UserModel {
|
||||
readonly _id: number
|
||||
|
||||
@Field(() => ID)
|
||||
readonly id: string
|
||||
|
||||
@ -36,8 +38,9 @@ export class UserModel {
|
||||
@Field({ nullable: true })
|
||||
readonly lastModified: Date
|
||||
|
||||
constructor(user: UserEntity) {
|
||||
this.id = user.id.toString()
|
||||
constructor(id: string, user: UserEntity) {
|
||||
this._id = user.id
|
||||
this.id = id
|
||||
this.username = user.username
|
||||
this.email = user.email
|
||||
|
||||
|
||||
@ -72,4 +72,10 @@ export class FormEntity {
|
||||
|
||||
@UpdateDateColumn()
|
||||
public lastModified: Date
|
||||
|
||||
constructor(partial?: Partial<FormEntity>) {
|
||||
if (partial) {
|
||||
Object.assign(this, partial)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
src/pipe/form/form.by.id.pipe.ts
Normal file
24
src/pipe/form/form.by.id.pipe.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
|
||||
@Injectable()
|
||||
export class FormByIdPipe implements PipeTransform<string, Promise<FormEntity>> {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
async transform(value: string, metadata: ArgumentMetadata): Promise<FormEntity> {
|
||||
const id = this.idService.decode(value)
|
||||
|
||||
console.log({
|
||||
id,
|
||||
value,
|
||||
})
|
||||
|
||||
return await this.formService.findById(id)
|
||||
}
|
||||
}
|
||||
3
src/pipe/form/index.ts
Normal file
3
src/pipe/form/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { FormByIdPipe } from './form.by.id.pipe'
|
||||
|
||||
export const formPipes = [FormByIdPipe]
|
||||
9
src/pipe/index.ts
Normal file
9
src/pipe/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { formPipes } from './form'
|
||||
import { submissionPipes } from './submission'
|
||||
import { userPipes } from './user'
|
||||
|
||||
export const pipes = [
|
||||
...formPipes,
|
||||
...submissionPipes,
|
||||
...userPipes,
|
||||
]
|
||||
3
src/pipe/submission/index.ts
Normal file
3
src/pipe/submission/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { SubmissionByIdPipe } from './submission.by.id.pipe'
|
||||
|
||||
export const submissionPipes = [SubmissionByIdPipe]
|
||||
19
src/pipe/submission/submission.by.id.pipe.ts
Normal file
19
src/pipe/submission/submission.by.id.pipe.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionService } from '../../service/submission/submission.service'
|
||||
|
||||
@Injectable()
|
||||
export class SubmissionByIdPipe implements PipeTransform<string, Promise<SubmissionEntity>> {
|
||||
constructor(
|
||||
private readonly submissionService: SubmissionService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
async transform(value: string, metadata: ArgumentMetadata): Promise<SubmissionEntity> {
|
||||
const id = this.idService.decode(value)
|
||||
|
||||
return await this.submissionService.findById(id)
|
||||
}
|
||||
}
|
||||
3
src/pipe/user/index.ts
Normal file
3
src/pipe/user/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { UserByIdPipe } from './user.by.id.pipe'
|
||||
|
||||
export const userPipes = [UserByIdPipe]
|
||||
19
src/pipe/user/user.by.id.pipe.ts
Normal file
19
src/pipe/user/user.by.id.pipe.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { UserService } from '../../service/user/user.service'
|
||||
|
||||
@Injectable()
|
||||
export class UserByIdPipe implements PipeTransform<string, Promise<UserEntity>> {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
async transform(value: string, metadata: ArgumentMetadata): Promise<UserEntity> {
|
||||
const id = this.idService.decode(value)
|
||||
|
||||
return await this.userService.findById(id)
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,10 @@
|
||||
|
||||
type ID = string | number
|
||||
|
||||
export class ContextCache<A = any> {
|
||||
private cache: {
|
||||
[key: string]: any
|
||||
} = {}
|
||||
|
||||
public getCacheKey(type: string, id: ID): string {
|
||||
public getCacheKey(type: string, id: number): string {
|
||||
return `${type}:${id}`
|
||||
}
|
||||
|
||||
|
||||
@ -7,12 +7,14 @@ import { FormModel } from '../../dto/form/form.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormCreateService } from '../../service/form/form.create.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class FormCreateMutation {
|
||||
constructor(
|
||||
private readonly createService: FormCreateService
|
||||
private readonly createService: FormCreateService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -27,6 +29,6 @@ export class FormCreateMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(FormEntity.name, form.id), form)
|
||||
|
||||
return new FormModel(form)
|
||||
return new FormModel(this.idService.encode(form.id), form)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,15 +3,19 @@ import { Args, ID, Mutation } from '@nestjs/graphql'
|
||||
import { Roles } from '../../decorator/roles.decorator'
|
||||
import { User } from '../../decorator/user.decorator'
|
||||
import { DeletedModel } from '../../dto/deleted.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormByIdPipe } from '../../pipe/form/form.by.id.pipe'
|
||||
import { FormDeleteService } from '../../service/form/form.delete.service'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
|
||||
@Injectable()
|
||||
export class FormDeleteMutation {
|
||||
constructor(
|
||||
private readonly deleteService: FormDeleteService,
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -19,16 +23,14 @@ export class FormDeleteMutation {
|
||||
@Roles('admin')
|
||||
async deleteForm(
|
||||
@User() user: UserEntity,
|
||||
@Args({ name: 'id', type: () => ID}) id: string,
|
||||
@Args('id', {type: () => ID}, FormByIdPipe) form: FormEntity,
|
||||
): Promise<DeletedModel> {
|
||||
const form = await this.formService.findById(id)
|
||||
|
||||
if (!form.isLive && !this.formService.isAdmin(form, user)) {
|
||||
throw new Error('invalid form')
|
||||
}
|
||||
|
||||
await this.deleteService.delete(id)
|
||||
await this.deleteService.delete(form.id)
|
||||
|
||||
return new DeletedModel(id)
|
||||
return new DeletedModel(this.idService.encode(form.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,12 +7,14 @@ import { FormPagerModel } from '../../dto/form/form.pager.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class FormListQuery {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -34,7 +36,7 @@ export class FormListQuery {
|
||||
forms.forEach(form => cache.add(cache.getCacheKey(FormEntity.name, form.id), form))
|
||||
|
||||
return new FormPagerModel(
|
||||
forms.map(form => new FormModel(form)),
|
||||
forms.map(form => new FormModel(this.idService.encode(form.id), form)),
|
||||
total,
|
||||
limit,
|
||||
start,
|
||||
|
||||
@ -4,30 +4,31 @@ import { User } from '../../decorator/user.decorator'
|
||||
import { FormModel } from '../../dto/form/form.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormByIdPipe } from '../../pipe/form/form.by.id.pipe'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class FormQuery {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => FormModel)
|
||||
async getFormById(
|
||||
getFormById(
|
||||
@User() user: UserEntity,
|
||||
@Args('id', {type: () => ID}) id,
|
||||
@Args('id', {type: () => ID}, FormByIdPipe) form: FormEntity,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormModel> {
|
||||
const form = await this.formService.findById(id)
|
||||
|
||||
): FormModel {
|
||||
if (!form.isLive && !this.formService.isAdmin(form, user)) {
|
||||
throw new Error('invalid form')
|
||||
}
|
||||
|
||||
cache.add(cache.getCacheKey(FormEntity.name, form.id), form)
|
||||
|
||||
return new FormModel(form)
|
||||
return new FormModel(this.idService.encode(form.id), form)
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,12 +11,14 @@ import { UserModel } from '../../dto/user/user.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Resolver(() => FormModel)
|
||||
export class FormResolver {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -26,7 +28,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormFieldModel[]> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
return form.fields?.map(field => new FormFieldModel(field)).sort((a,b) => a.idx - b.idx) || []
|
||||
}
|
||||
@ -37,7 +39,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormHookModel[]> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
return form.hooks?.map(hook => new FormHookModel(hook)) || []
|
||||
}
|
||||
@ -49,7 +51,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<boolean> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
if (!this.formService.isAdmin(form, user)) {
|
||||
throw new Error('no access to field')
|
||||
@ -65,7 +67,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormNotificationModel[]> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
if (!this.formService.isAdmin(form, user)) {
|
||||
throw new Error('no access to field')
|
||||
@ -80,7 +82,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<DesignModel> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
return new DesignModel(form.design)
|
||||
}
|
||||
@ -90,7 +92,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<PageModel> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
return new PageModel(form.startPage)
|
||||
}
|
||||
@ -100,7 +102,7 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<PageModel> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
return new PageModel(form.endPage)
|
||||
}
|
||||
@ -111,12 +113,12 @@ export class FormResolver {
|
||||
@Parent() parent: FormModel,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<UserModel> {
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent.id))
|
||||
const form = await cache.get<FormEntity>(cache.getCacheKey(FormEntity.name, parent._id))
|
||||
|
||||
if (!form.admin) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new UserModel(form.admin)
|
||||
return new UserModel(this.idService.encode(form.admin.id), form.admin)
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { FormEntity } from '../../entity/form.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { FormUpdateService } from '../../service/form/form.update.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
@ -15,6 +16,7 @@ export class FormUpdateMutation {
|
||||
constructor(
|
||||
private readonly updateService: FormUpdateService,
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -25,7 +27,7 @@ export class FormUpdateMutation {
|
||||
@Args({ name: 'form', type: () => FormUpdateInput }) input: FormUpdateInput,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormModel> {
|
||||
const form = await this.formService.findById(input.id)
|
||||
const form = await this.formService.findById(this.idService.decode(input.id))
|
||||
|
||||
if (!form.isLive && !this.formService.isAdmin(form, user)) {
|
||||
throw new Error('invalid form')
|
||||
@ -35,6 +37,6 @@ export class FormUpdateMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(FormEntity.name, form.id), form)
|
||||
|
||||
return new FormModel(form)
|
||||
return new FormModel(this.idService.encode(form.id), form)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,10 +4,16 @@ import { Roles } from '../../decorator/roles.decorator'
|
||||
import { User } from '../../decorator/user.decorator'
|
||||
import { ProfileModel } from '../../dto/profile/profile.model'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class ProfileQuery {
|
||||
constructor(
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => ProfileModel)
|
||||
@Roles('user')
|
||||
public me(
|
||||
@ -16,6 +22,6 @@ export class ProfileQuery {
|
||||
): ProfileModel {
|
||||
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
|
||||
|
||||
return new ProfileModel(user)
|
||||
return new ProfileModel(this.idService.encode(user.id), user)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { User } from '../../decorator/user.decorator'
|
||||
import { ProfileModel } from '../../dto/profile/profile.model'
|
||||
import { ProfileUpdateInput } from '../../dto/profile/profile.update.input'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ProfileUpdateService } from '../../service/profile/profile.update.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@ -12,6 +13,7 @@ import { ContextCache } from '../context.cache'
|
||||
export class ProfileUpdateMutation {
|
||||
constructor(
|
||||
private readonly updateService: ProfileUpdateService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -26,7 +28,7 @@ export class ProfileUpdateMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
|
||||
|
||||
return new ProfileModel(user)
|
||||
return new ProfileModel(this.idService.encode(user.id), user)
|
||||
}
|
||||
|
||||
@Mutation(() => ProfileModel)
|
||||
@ -40,6 +42,6 @@ export class ProfileUpdateMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
|
||||
|
||||
return new ProfileModel(user)
|
||||
return new ProfileModel(this.idService.encode(user.id), user)
|
||||
}
|
||||
}
|
||||
|
||||
@ -19,7 +19,7 @@ export class SubmissionFieldResolver {
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<FormFieldModel> {
|
||||
const submissionField = await cache.get<SubmissionFieldEntity>(
|
||||
cache.getCacheKey(SubmissionFieldEntity.name, parent.id)
|
||||
cache.getCacheKey(SubmissionFieldEntity.name, parent._id)
|
||||
)
|
||||
|
||||
const field = await cache.get<FormFieldEntity>(
|
||||
|
||||
@ -2,33 +2,31 @@ import { Injectable } from '@nestjs/common'
|
||||
import { Args, Context, ID, Mutation } from '@nestjs/graphql'
|
||||
import { User } from '../../decorator/user.decorator'
|
||||
import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model'
|
||||
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { SubmissionService } from '../../service/submission/submission.service'
|
||||
import { SubmissionByIdPipe } from '../../pipe/submission/submission.by.id.pipe'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionSetFieldService } from '../../service/submission/submission.set.field.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class SubmissionFinishMutation {
|
||||
constructor(
|
||||
private readonly submissionService: SubmissionService,
|
||||
private readonly setFieldService: SubmissionSetFieldService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Mutation(() => SubmissionProgressModel)
|
||||
async submissionFinish(
|
||||
@User() user: UserEntity,
|
||||
@Args({ name: 'submission', type: () => ID }) id: string,
|
||||
@Args({ name: 'submission', type: () => ID }, SubmissionByIdPipe) submission: SubmissionEntity,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionProgressModel> {
|
||||
const submission = await this.submissionService.findById(id)
|
||||
|
||||
await this.setFieldService.finishSubmission(submission)
|
||||
|
||||
cache.add(cache.getCacheKey(SubmissionEntity.name, submission.id), submission)
|
||||
|
||||
return new SubmissionProgressModel(submission)
|
||||
return new SubmissionProgressModel(this.idService.encode(submission.id), submission)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,31 +4,31 @@ import { User } from '../../decorator/user.decorator'
|
||||
import { SubmissionModel } from '../../dto/submission/submission.model'
|
||||
import { SubmissionPagerFilterInput } from '../../dto/submission/submission.pager.filter.input'
|
||||
import { SubmissionPagerModel } from '../../dto/submission/submission.pager.model'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { FormByIdPipe } from '../../pipe/form/form.by.id.pipe'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionService } from '../../service/submission/submission.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class SubmissionListQuery {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly submissionService: SubmissionService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => SubmissionPagerModel)
|
||||
async listSubmissions(
|
||||
@User() user: UserEntity,
|
||||
@Args('form', {type: () => ID}) id: string,
|
||||
@Args('form', {type: () => ID}, FormByIdPipe) form: FormEntity,
|
||||
@Args('start', {type: () => Int, defaultValue: 0, nullable: true}) start: number,
|
||||
@Args('limit', {type: () => Int, defaultValue: 50, nullable: true}) limit: number,
|
||||
@Args('filter', {type: () => SubmissionPagerFilterInput, defaultValue: new SubmissionPagerFilterInput()}) filter: SubmissionPagerFilterInput,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionPagerModel> {
|
||||
const form = await this.formService.findById(id)
|
||||
|
||||
const [submissions, total] = await this.submissionService.find(
|
||||
form,
|
||||
start,
|
||||
@ -42,7 +42,10 @@ export class SubmissionListQuery {
|
||||
})
|
||||
|
||||
return new SubmissionPagerModel(
|
||||
submissions.map(submission => new SubmissionModel(submission)),
|
||||
submissions.map(submission => new SubmissionModel(
|
||||
this.idService.encode(submission.id),
|
||||
submission
|
||||
)),
|
||||
total,
|
||||
limit,
|
||||
start,
|
||||
|
||||
@ -4,8 +4,9 @@ import { User } from '../../decorator/user.decorator'
|
||||
import { SubmissionModel } from '../../dto/submission/submission.model'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { SubmissionByIdPipe } from '../../pipe/submission/submission.by.id.pipe'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { SubmissionService } from '../../service/submission/submission.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionTokenService } from '../../service/submission/submission.token.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@ -13,20 +14,18 @@ import { ContextCache } from '../context.cache'
|
||||
export class SubmissionQuery {
|
||||
constructor(
|
||||
private readonly formService: FormService,
|
||||
private readonly submissionService: SubmissionService,
|
||||
private readonly tokenService: SubmissionTokenService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => SubmissionModel)
|
||||
async getSubmissionById(
|
||||
@User() user: UserEntity,
|
||||
@Args('id', {type: () => ID}) id: string,
|
||||
@Args('id', {type: () => ID}, SubmissionByIdPipe) submission: SubmissionEntity,
|
||||
@Args('token', {nullable: true}) token: string,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionModel> {
|
||||
const submission = await this.submissionService.findById(id)
|
||||
|
||||
if (
|
||||
!await this.tokenService.verify(token, submission.tokenHash)
|
||||
&& !this.formService.isAdmin(submission.form, user)
|
||||
@ -36,6 +35,6 @@ export class SubmissionQuery {
|
||||
|
||||
cache.add(cache.getCacheKey(SubmissionEntity.name, submission.id), submission)
|
||||
|
||||
return new SubmissionModel(submission)
|
||||
return new SubmissionModel(this.idService.encode(submission.id), submission)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,16 @@ import { SubmissionModel } from '../../dto/submission/submission.model'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { SubmissionFieldEntity } from '../../entity/submission.field.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Resolver(() => SubmissionModel)
|
||||
export class SubmissionResolver {
|
||||
constructor(
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ResolveField(() => [SubmissionFieldModel])
|
||||
async fields(
|
||||
@User() user: UserEntity,
|
||||
@ -16,12 +22,12 @@ export class SubmissionResolver {
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionFieldModel[]> {
|
||||
const submission = await cache.get<SubmissionEntity>(
|
||||
cache.getCacheKey(SubmissionEntity.name, parent.id)
|
||||
cache.getCacheKey(SubmissionEntity.name, parent._id)
|
||||
)
|
||||
|
||||
return submission.fields.map(field => {
|
||||
cache.add(cache.getCacheKey(SubmissionFieldEntity.name, field.id), field)
|
||||
return new SubmissionFieldModel(field)
|
||||
return new SubmissionFieldModel(this.idService.encode(field.id), field)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import { SubmissionProgressModel } from '../../dto/submission/submission.progres
|
||||
import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { SubmissionByIdPipe } from '../../pipe/submission/submission.by.id.pipe'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionService } from '../../service/submission/submission.service'
|
||||
import { SubmissionSetFieldService } from '../../service/submission/submission.set.field.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
@ -14,18 +16,17 @@ export class SubmissionSetFieldMutation {
|
||||
constructor(
|
||||
private readonly submissionService: SubmissionService,
|
||||
private readonly setFieldService: SubmissionSetFieldService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Mutation(() => SubmissionProgressModel)
|
||||
async submissionSetField(
|
||||
@User() user: UserEntity,
|
||||
@Args({ name: 'submission', type: () => ID }) id: string,
|
||||
@Args({ name: 'submission', type: () => ID }, SubmissionByIdPipe) submission: SubmissionEntity,
|
||||
@Args({ name: 'field', type: () => SubmissionSetFieldInput }) input: SubmissionSetFieldInput,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionProgressModel> {
|
||||
const submission = await this.submissionService.findById(id)
|
||||
|
||||
if (!await this.submissionService.isOwner(submission, input.token)) {
|
||||
throw new Error('no access to submission')
|
||||
}
|
||||
@ -34,6 +35,6 @@ export class SubmissionSetFieldMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(SubmissionEntity.name, submission.id), submission)
|
||||
|
||||
return new SubmissionProgressModel(submission)
|
||||
return new SubmissionProgressModel(this.idService.encode(submission.id), submission)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,9 +4,12 @@ import { IpAddress } from '../../decorator/ip.address.decorator'
|
||||
import { User } from '../../decorator/user.decorator'
|
||||
import { SubmissionProgressModel } from '../../dto/submission/submission.progress.model'
|
||||
import { SubmissionStartInput } from '../../dto/submission/submission.start.input'
|
||||
import { FormEntity } from '../../entity/form.entity'
|
||||
import { SubmissionEntity } from '../../entity/submission.entity'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { FormByIdPipe } from '../../pipe/form/form.by.id.pipe'
|
||||
import { FormService } from '../../service/form/form.service'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { SubmissionStartService } from '../../service/submission/submission.start.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@ -15,19 +18,18 @@ export class SubmissionStartMutation {
|
||||
constructor(
|
||||
private readonly startService: SubmissionStartService,
|
||||
private readonly formService: FormService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Mutation(() => SubmissionProgressModel)
|
||||
async submissionStart(
|
||||
@User() user: UserEntity,
|
||||
@Args({ name: 'form', type: () => ID }) id: string,
|
||||
@Args({ name: 'form', type: () => ID }, FormByIdPipe) form: FormEntity,
|
||||
@Args({ name: 'submission', type: () => SubmissionStartInput }) input: SubmissionStartInput,
|
||||
@IpAddress() ipAddr: string,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<SubmissionProgressModel> {
|
||||
const form = await this.formService.findById(id)
|
||||
|
||||
if (!form.isLive && !this.formService.isAdmin(form, user)) {
|
||||
throw new Error('invalid form')
|
||||
}
|
||||
@ -36,6 +38,6 @@ export class SubmissionStartMutation {
|
||||
|
||||
cache.add(cache.getCacheKey(SubmissionEntity.name, submission.id), submission)
|
||||
|
||||
return new SubmissionProgressModel(submission)
|
||||
return new SubmissionProgressModel(this.idService.encode(submission.id), submission)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,15 @@ import { Roles } from '../../decorator/roles.decorator'
|
||||
import { User } from '../../decorator/user.decorator'
|
||||
import { DeletedModel } from '../../dto/deleted.model'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { UserByIdPipe } from '../../pipe/user/user.by.id.pipe'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { UserDeleteService } from '../../service/user/user.delete.service'
|
||||
|
||||
@Injectable()
|
||||
export class UserDeleteMutation {
|
||||
constructor(
|
||||
private readonly deleteService: UserDeleteService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -17,14 +20,14 @@ export class UserDeleteMutation {
|
||||
@Roles('admin')
|
||||
async deleteUser(
|
||||
@User() auth: UserEntity,
|
||||
@Args({ name: 'id', type: () => ID}) id: string,
|
||||
@Args({ name: 'id', type: () => ID}, UserByIdPipe) user: UserEntity,
|
||||
): Promise<DeletedModel> {
|
||||
if (auth.id.toString() === id) {
|
||||
if (auth.id === user.id) {
|
||||
throw new Error('cannot delete your own user')
|
||||
}
|
||||
|
||||
await this.deleteService.delete(id)
|
||||
await this.deleteService.delete(user.id)
|
||||
|
||||
return new DeletedModel(id)
|
||||
return new DeletedModel(this.idService.encode(user.id))
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { Roles } from '../../decorator/roles.decorator'
|
||||
import { UserModel } from '../../dto/user/user.model'
|
||||
import { UserPagerModel } from '../../dto/user/user.pager.model'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { UserService } from '../../service/user/user.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@ -10,6 +11,7 @@ import { ContextCache } from '../context.cache'
|
||||
export class UserListQuery {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -25,7 +27,7 @@ export class UserListQuery {
|
||||
return new UserPagerModel(
|
||||
entities.map(entity => {
|
||||
cache.add(cache.getCacheKey(UserEntity.name, entity.id), entity)
|
||||
return new UserModel(entity)
|
||||
return new UserModel(this.idService.encode(entity.id), entity)
|
||||
}),
|
||||
total,
|
||||
limit,
|
||||
|
||||
@ -3,26 +3,25 @@ import { Args, Context, ID, Query } from '@nestjs/graphql'
|
||||
import { Roles } from '../../decorator/roles.decorator'
|
||||
import { UserModel } from '../../dto/user/user.model'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { UserService } from '../../service/user/user.service'
|
||||
import { UserByIdPipe } from '../../pipe/user/user.by.id.pipe'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
|
||||
@Injectable()
|
||||
export class UserQuery {
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@Query(() => UserModel)
|
||||
@Roles('admin')
|
||||
public async getUserById(
|
||||
@Args('id', {type: () => ID}) id: string,
|
||||
public getUserById(
|
||||
@Args('id', {type: () => ID}, UserByIdPipe) user: UserEntity,
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<UserModel> {
|
||||
const user = await this.userService.findById(id)
|
||||
|
||||
): UserModel {
|
||||
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
|
||||
|
||||
return new UserModel(user)
|
||||
return new UserModel(this.idService.encode(user.id), user)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ export class UserResolver {
|
||||
@Context('cache') cache: ContextCache,
|
||||
): Promise<string[]> {
|
||||
return this.returnFieldForSuperuser(
|
||||
await cache.get<UserEntity>(cache.getCacheKey(UserEntity.name, parent.id)),
|
||||
await cache.get<UserEntity>(cache.getCacheKey(UserEntity.name, parent._id)),
|
||||
user,
|
||||
c => c.roles
|
||||
)
|
||||
|
||||
@ -5,6 +5,7 @@ import { User } from '../../decorator/user.decorator'
|
||||
import { UserModel } from '../../dto/user/user.model'
|
||||
import { UserUpdateInput } from '../../dto/user/user.update.input'
|
||||
import { UserEntity } from '../../entity/user.entity'
|
||||
import { IdService } from '../../service/id.service'
|
||||
import { UserService } from '../../service/user/user.service'
|
||||
import { UserUpdateService } from '../../service/user/user.update.service'
|
||||
import { ContextCache } from '../context.cache'
|
||||
@ -14,6 +15,7 @@ export class UserUpdateMutation {
|
||||
constructor(
|
||||
private readonly updateService: UserUpdateService,
|
||||
private readonly userService: UserService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -28,12 +30,12 @@ export class UserUpdateMutation {
|
||||
throw new Error('cannot update your own user')
|
||||
}
|
||||
|
||||
const user = await this.userService.findById(input.id)
|
||||
const user = await this.userService.findById(this.idService.decode(input.id))
|
||||
|
||||
await this.updateService.update(user, input)
|
||||
|
||||
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
|
||||
|
||||
return new UserModel(user)
|
||||
return new UserModel(this.idService.encode(user.id), user)
|
||||
}
|
||||
}
|
||||
|
||||
32
src/service/id.service.ts
Normal file
32
src/service/id.service.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import Hashids from 'hashids'
|
||||
|
||||
@Injectable()
|
||||
export class IdService {
|
||||
private readonly hashids: Hashids
|
||||
|
||||
constructor(
|
||||
readonly config: ConfigService
|
||||
) {
|
||||
this.hashids = new Hashids(config.get('SECRET_KEY'), 6)
|
||||
}
|
||||
|
||||
public encode(id: number): string {
|
||||
return this.hashids.encode([ id ])
|
||||
}
|
||||
|
||||
public decode(raw: string): number {
|
||||
if (!this.hashids.isValidId(raw)) {
|
||||
throw new Error('invalid id passed')
|
||||
}
|
||||
|
||||
const results: number[] = this.hashids.decode(raw) as number[]
|
||||
|
||||
if (results[0] === undefined) {
|
||||
throw new Error('invalid id passed')
|
||||
}
|
||||
|
||||
return results[0]
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import Redis from 'ioredis'
|
||||
import { PinoLogger } from 'nestjs-pino'
|
||||
import { authServices } from './auth'
|
||||
import { formServices } from './form'
|
||||
import { IdService } from './id.service'
|
||||
import { InstallationMetricsService } from './installation.metrics.service'
|
||||
import { MailService } from './mail.service'
|
||||
import { profileServices } from './profile'
|
||||
@ -44,4 +45,5 @@ export const services = [
|
||||
})
|
||||
},
|
||||
},
|
||||
IdService,
|
||||
]
|
||||
|
||||
@ -58,7 +58,7 @@ export class SubmissionService {
|
||||
return await qb.getManyAndCount()
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SubmissionEntity> {
|
||||
async findById(id: number): Promise<SubmissionEntity> {
|
||||
const submission = await this.submissionRepository.findOne(id);
|
||||
|
||||
if (!submission) {
|
||||
|
||||
@ -11,7 +11,7 @@ export class UserDeleteService {
|
||||
) {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
async delete(id: number): Promise<void> {
|
||||
await this.userRepository.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export class UserService {
|
||||
return await qb.getManyAndCount()
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<UserEntity> {
|
||||
async findById(id: number): Promise<UserEntity> {
|
||||
const user = await this.userRepository.findOne(id);
|
||||
|
||||
if (!user) {
|
||||
|
||||
@ -4392,6 +4392,11 @@ has@^1.0.3:
|
||||
dependencies:
|
||||
function-bind "^1.1.1"
|
||||
|
||||
hashids@^2.2.10:
|
||||
version "2.2.10"
|
||||
resolved "https://registry.yarnpkg.com/hashids/-/hashids-2.2.10.tgz#82f45538cf03ce73e31b78d1abe78d287cf760c4"
|
||||
integrity sha512-nXnYums7F8B5Y+GSThutLPlKMaamW1yjWNZVt0WModiJfdjaDZHnhYTWblS+h1OoBx3yjwiBwxldPP3nIbFSSA==
|
||||
|
||||
he@1.2.0, he@^1.2.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user