dev/snippet (#130)

This commit is contained in:
2021-10-22 21:47:53 +08:00
committed by GitHub
parent b3f9adba7f
commit 4b7378a492
27 changed files with 555 additions and 47 deletions

View File

@@ -12,6 +12,7 @@ import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor'
import { CountingInterceptor } from './common/interceptors/counting.interceptor'
import { JSONSerializeInterceptor } from './common/interceptors/json-serialize.interceptor'
import { ResponseInterceptor } from './common/interceptors/response.interceptor'
import { AttachHeaderTokenMiddleware } from './common/middlewares/attach-auth.middleware'
import {
DATA_DIR,
LOGGER_DIR,
@@ -40,6 +41,7 @@ import { RecentlyModule } from './modules/recently/recently.module'
import { SayModule } from './modules/say/say.module'
import { SearchModule } from './modules/search/search.module'
import { SitemapModule } from './modules/sitemap/sitemap.module'
import { SnippetModule } from './modules/snippet/snippet.module'
import { ToolModule } from './modules/tool/tool.module'
import { UserModule } from './modules/user/user.module'
import { CacheModule } from './processors/cache/cache.module'
@@ -103,6 +105,7 @@ mkdirs()
GatewayModule,
HelperModule,
LoggerModule,
SnippetModule,
],
controllers: [AppController],
providers: [
@@ -141,11 +144,6 @@ mkdirs()
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// FIXME: nestjs 8 middleware bug
// consumer
// .apply(AnalyzeMiddleware)
// .forRoutes({ path: '(.*?)', method: RequestMethod.GET })
// .apply(SkipBrowserDefaultRequestMiddleware, SecurityMiddleware)
// .forRoutes({ path: '(.*?)', method: RequestMethod.ALL })
consumer.apply(AttachHeaderTokenMiddleware).forRoutes('*')
}
}

View File

@@ -1,5 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'
import { AuthGuard as _AuthGuard } from '@nestjs/passport'
import { mockUser1 } from '~/mock/user.mock'
import { isTest } from '~/utils/index.util'
import { getNestExecutionContextRequest } from '~/utils/nest.util'
/**
@@ -15,6 +17,12 @@ export class JWTAuthGuard extends _AuthGuard('jwt') implements CanActivate {
return true
}
/// for e2e-test mock user
if (isTest) {
request.user = { ...mockUser1 }
return true
}
return super.canActivate(context)
}

View File

@@ -0,0 +1,23 @@
/**
* 把 URL Search 上的 `token` 附加到 Header Authorization 上
* @author Innei <https://innei.ren>
*/
import { Injectable, NestMiddleware } from '@nestjs/common'
import { IncomingMessage, ServerResponse } from 'http'
import { parseRelativeUrl } from '~/utils/ip.util'
@Injectable()
export class AttachHeaderTokenMiddleware implements NestMiddleware {
async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
// @ts-ignore
const url = req.originalUrl?.replace(/^\/api(\/v\d)?/, '')
const parser = parseRelativeUrl(url)
if (parser.searchParams.get('token')) {
req.headers.authorization = parser.searchParams.get('token')
}
next()
}
}

21
src/mock/user.mock.ts Normal file
View File

@@ -0,0 +1,21 @@
import { UserModel } from '~/modules/user/user.model'
export const mockUser1: UserModel = {
id: '1',
name: 'John Doe',
mail: 'example@ee.com',
password: '**********',
authCode: '*****',
username: 'johndoe',
created: new Date('2021/1/1 10:00:11'),
}
export const mockUser2: UserModel = {
id: '2',
name: 'Shawn Carter',
mail: 'example@ee.com',
password: '**********',
authCode: '*****',
username: 'shawn',
created: new Date('2020/10/10 19:22:22'),
}

View File

@@ -1,5 +1,4 @@
import { PartialType } from '@nestjs/mapped-types'
import { ApiProperty } from '@nestjs/swagger'
import { modelOptions, prop } from '@typegoose/typegoose'
import { Transform } from 'class-transformer'
import {
@@ -24,20 +23,17 @@ export enum PageType {
},
})
export class PageModel extends WriteBaseModel {
@ApiProperty({ description: 'Slug', required: true })
@prop({ trim: 1, index: true, required: true, unique: true })
@IsString()
@IsNotEmpty()
slug!: string
@ApiProperty({ description: 'SubTitle', required: false })
@prop({ trim: true })
@IsString()
@IsOptional()
@IsNilOrString()
subtitle?: string | null
@ApiProperty({ description: 'Order', required: false })
@prop({ default: 1 })
@IsInt()
@Min(0)
@@ -45,10 +41,6 @@ export class PageModel extends WriteBaseModel {
@Transform(({ value }) => parseInt(value))
order!: number
@ApiProperty({
enum: PageType,
required: false,
})
@prop({ default: 'md' })
@IsEnum(PageType)
@IsOptional()

View File

@@ -0,0 +1,95 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Query,
} from '@nestjs/common'
import { Auth } from '~/common/decorator/auth.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { IsMaster } from '~/common/decorator/role.decorator'
import { MongoIdDto } from '~/shared/dto/id.dto'
import { PagerDto } from '~/shared/dto/pager.dto'
import { transformDataToPaginate } from '~/utils/transfrom.util'
import { SnippetModel } from './snippet.model'
import { SnippetService } from './snippet.service'
@ApiName
@Controller('snippets')
export class SnippetController {
constructor(private readonly snippetService: SnippetService) {}
@Get('/')
@Auth()
async getList(@Query() query: PagerDto) {
const { page, size, select = '' } = query
return transformDataToPaginate(
await this.snippetService.model.paginate(
{},
{ page, limit: size, select },
),
)
}
@Post('/')
@Auth()
async create(@Body() body: SnippetModel) {
return await this.snippetService.create(body)
}
@Get('/:id')
async getSnippetById(
@Param() param: MongoIdDto,
@IsMaster() isMaster: boolean,
) {
const { id } = param
const snippet = await this.snippetService.getSnippetById(id)
if (snippet.private && !isMaster) {
throw new ForbiddenException('snippet is private')
}
return snippet
}
@Get('/:reference/:name')
async getSnippetByName(
@Param('name') name: string,
@Param('reference') reference: string,
@IsMaster() isMaster: boolean,
) {
if (typeof name !== 'string') {
throw new ForbiddenException('name should be string')
}
if (typeof reference !== 'string') {
throw new ForbiddenException('reference should be string')
}
const snippet = await this.snippetService.getSnippetByName(name, reference)
if (snippet.private && !isMaster) {
throw new ForbiddenException('snippet is private')
}
return snippet
}
@Put('/:id')
@Auth()
async update(@Param() param: MongoIdDto, @Body() body: SnippetModel) {
const { id } = param
await this.snippetService.update(id, body)
return await this.snippetService.getSnippetById(id)
}
@Delete('/:id')
@Auth()
async delete(@Param() param: MongoIdDto) {
const { id } = param
await this.snippetService.delete(id)
}
}

View File

View File

@@ -0,0 +1,73 @@
import { index, modelOptions, prop } from '@typegoose/typegoose'
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator'
import { BaseModel } from '~/shared/model/base.model'
export enum SnippetType {
JSON = 'json',
Function = 'function',
Text = 'text',
}
@modelOptions({
options: {
customName: 'snippet',
},
schemaOptions: {
timestamps: {
createdAt: 'created',
updatedAt: 'updated',
},
},
})
@index({ name: 1, reference: 1 })
@index({ type: 1 })
export class SnippetModel extends BaseModel {
@prop({ default: SnippetType['JSON'] })
@IsEnum(SnippetType)
type: SnippetType
@prop({ default: false })
@IsBoolean()
@IsOptional()
private: boolean
@prop({ require: true })
@IsString()
@IsNotEmpty()
raw: string
@prop({ require: true, trim: true })
@IsNotEmpty()
@Matches(/^[a-zA-Z0-9_]{1,30}$/, {
message: 'name 只能使用英文字母和数字下划线且不超过 30 个字符',
})
name: string
// 适用于
@prop({ default: 'root' })
@IsString()
@IsOptional()
@IsNotEmpty()
reference: string
// 注释
@prop({})
@IsString()
@IsOptional()
comment?: string
// 类型注释
@prop({ maxlength: 20 })
@MaxLength(20)
@IsString()
@IsOptional()
metatype?: string
}

View File

@@ -0,0 +1,13 @@
/**
* 数据配置区块
*/
import { Module } from '@nestjs/common'
import { SnippetController } from './snippet.controller'
import { SnippetService } from './snippet.service'
@Module({
controllers: [SnippetController],
exports: [SnippetService],
providers: [SnippetService],
})
export class SnippetModule {}

View File

@@ -0,0 +1,44 @@
# 数据区块 (Snippet)
拟定于存储一些动态扩展配置. 一期实现存储 JSON 和 plain text 的区块
二期实现低配云函数
拟定:
JSON:
```
input: json `{"foo":"bar"}`
output:
{
metatype: null,
type: 'json',
data: {
foo: 'bar'
},
raw: '{"foo":"bar"}',
id: xx
}
```
serverless function
```
input: `module.exports = ctx => { return 'foo' }`
output:
{
raw: `module.exports = ctx => { return 'foo' }`,
data: 'foo',
....
}
```

View File

@@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { InjectModel } from 'nestjs-typegoose'
import { SnippetModel, SnippetType } from './snippet.model'
@Injectable()
export class SnippetService {
constructor(
@InjectModel(SnippetModel)
private readonly snippetModel: MongooseModel<SnippetModel>,
) {}
get model() {
return this.snippetModel
}
async create(model: SnippetModel) {
const isExist = await this.model.countDocuments({
name: model.name,
reference: model.reference || 'root',
})
if (isExist) {
throw new BadRequestException('snippet is exist')
}
// 验证正确类型
await this.validateType(model)
return await this.model.create({ ...model, created: new Date() })
}
async update(id: string, model: SnippetModel) {
await this.validateType(model)
await this.model.updateOne(
{
_id: id,
},
model,
)
}
async delete(id: string) {
await this.model.deleteOne({ _id: id })
}
private async validateType(model: SnippetModel) {
switch (model.type) {
case SnippetType.JSON: {
const isValidJSON = JSON.parse(model.raw)
if (!isValidJSON) {
throw new BadRequestException('content is not valid json')
}
break
}
case SnippetType.Function:
// TODO
throw new BadRequestException(
'Serverless functions are not currently supported',
)
case SnippetType.Text:
default: {
break
}
}
}
async getSnippetById(id: string) {
const doc = await this.model.findById(id).lean()
return this.attachSnippet(doc)
}
/**
*
* @param name
* @param reference 引用类型, 可以理解为 type
* @returns
*/
async getSnippetByName(name: string, reference: string) {
const doc = await this.model.findOne({ name, reference }).lean()
return this.attachSnippet(doc)
}
async attachSnippet(model: SnippetModel) {
switch (model.type) {
case SnippetType.JSON: {
Reflect.set(model, 'data', JSON.parse(model.raw))
break
}
case SnippetType.Text: {
break
}
}
return model
}
// TODO serverless function
// async runSnippet(model: SnippetModel) {}
}

View File

@@ -10,6 +10,7 @@ import { PageModel } from '~/modules/page/page.model'
import { ProjectModel } from '~/modules/project/project.model'
import { RecentlyModel } from '~/modules/recently/recently.model'
import { SayModel } from '~/modules/say/say.model'
import { SnippetModel } from '~/modules/snippet/snippet.model'
import { CategoryModel } from '../../modules/category/category.model'
import { PostModel } from '../../modules/post/post.model'
import { UserModel } from '../../modules/user/user.model'
@@ -27,6 +28,7 @@ const models = TypegooseModule.forFeature([
ProjectModel,
RecentlyModel,
SayModel,
SnippetModel,
UserModel,
])
@Module({

View File

@@ -25,6 +25,7 @@ export class BaseModel {
@Field(() => Date)
created?: Date
@Field(() => ID)
@ApiHideProperty()
id?: string
static get protectedKeys() {

View File

@@ -3,8 +3,11 @@ import { isObject } from 'lodash'
export * from './ip.util'
export const isDev = process.env.NODE_ENV == 'development'
export const isTest = !!process.env.TEST
export const md5 = (text: string) =>
require('crypto').createHash('md5').update(text).digest('hex')
export function getAvatar(mail: string) {
if (!mail) {
return ''
@@ -20,33 +23,6 @@ export function hasChinese(str: string) {
return escape(str).indexOf('%u') < 0 ? false : true
}
export const escapeShell = function (cmd: string) {
return '"' + cmd.replace(/(["\s'$`\\])/g, '\\$1') + '"'
}
export function arrDifference(a1: string[], a2: string[]) {
const a = [],
diff = []
for (let i = 0; i < a1.length; i++) {
a[a1[i]] = true
}
for (let i = 0; i < a2.length; i++) {
if (a[a2[i]]) {
delete a[a2[i]]
} else {
a[a2[i]] = true
}
}
for (const k in a) {
diff.push(k)
}
return diff
}
export function deleteKeys<T extends KV>(
target: T,
keys: (keyof T)[],