feat: middleware & user module init

This commit is contained in:
Innei
2021-08-31 16:56:55 +08:00
parent c4ab247ca6
commit 44b835d177
24 changed files with 681 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
import argv from 'argv'
import { isDev } from './utils'
import { isDev } from './utils/index.util'
export const CROSS_DOMAIN = {
allowedOrigins: [
'innei.ren',

View File

@@ -1,8 +1,17 @@
import { CacheInterceptor, Module } from '@nestjs/common'
import {
CacheInterceptor,
MiddlewareConsumer,
Module,
NestModule,
RequestMethod,
} from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { APP_INTERCEPTOR } from '@nestjs/core'
import { AppController } from './app.controller'
import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor'
import { AnalyzeMiddleware } from './common/middlewares/analyze.middleware'
import { SkipBrowserDefaultRequestMiddleware } from './common/middlewares/favicon.middleware'
import { SecurityMiddleware } from './common/middlewares/security.middleware'
import { AuthModule } from './modules/auth/auth.module'
// must after post
import { CategoryModule } from './modules/category/category.module'
@@ -47,4 +56,12 @@ import { HelperModule } from './processors/helper/helper.module'
},
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AnalyzeMiddleware)
.forRoutes({ path: '*', method: RequestMethod.GET })
.apply(SkipBrowserDefaultRequestMiddleware, SecurityMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL })
}
}

View File

@@ -1,7 +1,7 @@
import { applyDecorators, UseGuards } from '@nestjs/common'
import { AuthGuard } from '@nestjs/passport'
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger'
import { isDev } from '~/utils'
import { isDev } from '~/utils/index.util'
export function Auth() {
const decorators = []

View File

@@ -9,7 +9,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
import { FastifyRequest } from 'fastify'
import { getIp } from '~/utils/ip'
import { getIp } from '~/utils/ip.util'
export type IpRecord = {
ip: string

View File

@@ -0,0 +1,66 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common'
import { FastifyReply, FastifyRequest } from 'fastify'
import { isDev } from '~/utils/index.util'
import { getIp } from '../../utils/ip.util'
import { writeFileSync } from 'fs'
import { LOGGER_DIR } from '~/constants/path.constant'
import { resolve } from 'path'
type myError = {
readonly status: number
readonly statusCode?: number
readonly message?: string
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger('捕获异常')
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp()
const response = ctx.getResponse<FastifyReply>()
const request = ctx.getRequest<FastifyRequest>()
const status =
exception instanceof HttpException
? exception.getStatus()
: (exception as myError)?.status ||
(exception as myError)?.statusCode ||
HttpStatus.INTERNAL_SERVER_ERROR
if (isDev) {
console.error(exception)
} else {
const ip = getIp(request)
this.logger.warn(
`IP: ${ip} 错误信息: (${status}) ${
(exception as any)?.response?.message ||
(exception as myError)?.message ||
''
} Path: ${decodeURI(request.raw.url)}`,
)
writeFileSync(
resolve(LOGGER_DIR, 'error.log'),
`[${new Date().toISOString()}] ${decodeURI(request.raw.url)}: ${
(exception as any)?.response?.message ||
(exception as myError)?.message
} \n ${(exception as Error).stack || ''} \n`,
{ encoding: 'utf-8', flag: 'a+' },
)
}
response.status(status).send({
ok: 0,
message:
(exception as any)?.response?.message ||
(exception as any)?.message ||
'未知错误',
})
}
}

View File

@@ -0,0 +1,38 @@
/*
* @Author: Innei
* @Date: 2020-04-30 19:09:37
* @LastEditTime: 2020-07-08 21:35:06
* @LastEditors: Innei
* @FilePath: /mx-server/src/core/guards/spider.guard.ts
* @Coding with Love
*/
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common'
import { FastifyRequest } from 'fastify'
import { Observable } from 'rxjs'
import { isDev } from '~/utils/index.util'
@Injectable()
export class SpiderGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
if (isDev) {
return true
}
const http = context.switchToHttp()
const request = http.getRequest<FastifyRequest>()
const headers = request.headers
const ua: string = headers['user-agent'] || ''
const isSpiderUA = !!ua.match(/(Scrapy|Curl|HttpClient|python|requests)/i)
if (ua && !isSpiderUA) {
return true
}
throw new ForbiddenException('爬虫, 禁止')
}
}

View File

@@ -0,0 +1,47 @@
/**
* Logging interceptor.
* @file 日志拦截器
* @module interceptor/logging
* @author Surmon <https://github.com/surmon-china>
*/
import { Observable } from 'rxjs'
import { tap } from 'rxjs/operators'
import {
Injectable,
NestInterceptor,
CallHandler,
ExecutionContext,
Logger,
} from '@nestjs/common'
import { isDev } from '~/utils/index.util'
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private logger: Logger
constructor() {
this.logger = new Logger(LoggingInterceptor.name)
}
intercept(
context: ExecutionContext,
next: CallHandler<any>,
): Observable<any> {
const call$ = next.handle()
if (!isDev) {
return call$
}
const request = context.switchToHttp().getRequest()
const content = request.method + ' -> ' + request.url
Logger.debug('+++ 收到请求:' + content, LoggingInterceptor.name)
const now = +new Date()
return call$.pipe(
tap(() =>
this.logger.debug(
'--- 响应请求:' + content + ` +${+new Date() - now}ms`,
),
),
)
}
}

View File

@@ -0,0 +1,101 @@
/*
* @Author: Innei
* @Date: 2020-11-24 16:20:37
* @LastEditTime: 2021-03-21 20:12:56
* @LastEditors: Innei
* @FilePath: /server/shared/core/interceptors/response.interceptors.ts
* Mark: Coding with Love
*/
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
UnprocessableEntityException,
} from '@nestjs/common'
import { isArrayLike, isObjectLike } from 'lodash'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import snakecaseKeys from 'snakecase-keys'
export interface Response<T> {
data: T
}
@Injectable()
export class ResponseInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<Response<T>> {
const reorganize = (data) => {
if (!data) {
throw new UnprocessableEntityException('数据丢失了(。 ́︿ ̀。)')
}
return typeof data !== 'object' || data.__proto__.constructor === Object
? { ...data }
: { data }
}
return next.handle().pipe(
map((data) =>
typeof data === 'undefined'
? // HINT: hack way to solve `undefined` as cache value set into redis got an error.
''
: typeof data === 'object' && data !== null
? { ...reorganize(data) }
: data,
),
)
}
}
export class JSONSerializeInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map((data) => {
return this.serialize(data)
}),
)
}
private serialize(obj: any) {
if (!isObjectLike(obj)) {
return obj
}
if (isArrayLike(obj)) {
obj = Array.from(obj).map((i) => {
return this.serialize(i)
})
} else {
// if is Object
if (obj.toJSON || obj.toObject) {
obj = obj.toJSON?.() ?? obj.toObject?.()
}
const keys = Object.keys(obj)
for (const key of keys) {
const val = obj[key]
// first
if (!isObjectLike(val)) {
continue
}
if (val.toJSON) {
obj[key] = val.toJSON()
// second
if (!isObjectLike(obj[key])) {
continue
}
}
obj[key] = this.serialize(obj[key])
// obj[key] = snakecaseKeys(obj[key])
}
obj = snakecaseKeys(obj)
delete obj.v
}
return obj
}
}

View File

@@ -0,0 +1,7 @@
import { NestMiddleware } from '@nestjs/common'
// TODO:
export class AnalyzeMiddleware implements NestMiddleware {
use(req, res, next) {
next()
}
}

View File

@@ -0,0 +1,17 @@
import { Injectable, NestMiddleware } from '@nestjs/common'
import { IncomingMessage, ServerResponse } from 'http'
import { parseRelativeUrl } from '~/utils/ip.util'
@Injectable()
export class SkipBrowserDefaultRequestMiddleware implements NestMiddleware {
async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
// @ts-ignore
const url = parseRelativeUrl(req.originalUrl).pathname
if (url.match(/favicon.ico$/) || url.match(/manifest.json$/)) {
res.writeHead(204)
return res.end()
}
next()
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable, NestMiddleware } from '@nestjs/common'
import { IncomingMessage, ServerResponse } from 'http'
import { parseRelativeUrl } from '~/utils/ip.util'
// 用于屏蔽 PHP 的请求
@Injectable()
export class SecurityMiddleware implements NestMiddleware {
async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
// @ts-ignore
const url = parseRelativeUrl(req.originalUrl).pathname
if (url.match(/\.php$/g)) {
res.statusMessage =
'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.'
return res.writeHead(666).end()
} else if (url.match(/\/(adminer|admin|wp-login)$/g)) {
res.statusMessage = 'Hey, What the fuck are you doing!'
return res.writeHead(200).end()
} else next()
}
}

View File

@@ -8,7 +8,7 @@
*/
import { homedir } from 'os'
import { join } from 'path'
import { isDev } from '~/utils'
import { isDev } from '~/utils/index.util'
export const HOME = homedir()

View File

@@ -2,10 +2,16 @@ import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { NestFastifyApplication } from '@nestjs/platform-fastify'
import { fastifyApp } from './common/adapt/fastify'
import { isDev } from './utils'
import { isDev } from './utils/index.util'
import { CacheInterceptor, Logger } from '@nestjs/common'
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
import { CROSS_DOMAIN } from './app.config'
import {
JSONSerializeInterceptor,
ResponseInterceptor,
} from './common/interceptors/response.interceptors'
import { SpiderGuard } from './common/guard/spider.guard'
import { LoggingInterceptor } from './common/interceptors/logging.interceptor'
// const PORT = parseInt(process.env.PORT) || 2333
const PORT = 2333
const APIVersion = 1
@@ -30,6 +36,10 @@ async function bootstrap() {
})
app.setGlobalPrefix(isDev ? '' : `api/v${APIVersion}`)
app.useGlobalInterceptors(new ResponseInterceptor())
app.useGlobalInterceptors(new JSONSerializeInterceptor())
app.useGlobalInterceptors(new LoggingInterceptor())
app.useGlobalGuards(new SpiderGuard())
if (isDev) {
const options = new DocumentBuilder()
.setTitle('API')

View File

@@ -1,7 +1,6 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common'
import { Injectable } from '@nestjs/common'
import { ReturnModelType } from '@typegoose/typegoose'
import { InjectModel } from 'nestjs-typegoose'
import { PostService } from '../post/post.service'
import { CategoryModel } from './category.model'
@Injectable()

View File

@@ -0,0 +1,18 @@
import { Controller, Get } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { UserService } from '../user/user.service'
@Controller({
path: '/init',
})
@ApiTags('Init Routes')
export class InitController {
constructor(private readonly userService: UserService) {}
@Get('/')
async isInit() {
return {
isInit: await this.userService.hasMaster(),
}
}
}

View File

@@ -1,8 +1,12 @@
import { Module } from '@nestjs/common'
import { UserModule } from '../user/user.module'
import { InitController } from './init.controller'
import { InitService } from './init.service'
@Module({
providers: [InitService],
exports: [InitService],
controllers: [InitController],
imports: [UserModule],
})
export class InitModule {}

View File

@@ -0,0 +1,78 @@
import { ApiProperty } from '@nestjs/swagger'
import {
IsEmail,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUrl,
} from 'class-validator'
class UserOptionDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@ApiProperty({ example: '我是练习时长两年半的个人练习生' })
readonly introduce?: string
@ApiProperty({ required: false, example: 'example@example.com' })
@IsEmail()
@IsOptional()
readonly mail?: string
@ApiProperty({ required: false, example: 'http://example.com' })
@IsUrl({ require_protocol: true }, { message: '请更正为正确的网址' })
@IsOptional()
readonly url?: string
@ApiProperty({ required: false })
@IsString()
@IsOptional()
name?: string
@ApiProperty({ required: false })
@IsUrl({ require_protocol: true })
@IsOptional()
readonly avatar?: string
@IsOptional()
@IsObject()
@ApiProperty({ description: '各种社交 id 记录' })
readonly socialIds?: Record<string, any>
}
export class UserDto extends UserOptionDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
readonly username: string
@IsString()
@ApiProperty()
@IsNotEmpty()
readonly password: string
}
export class LoginDto {
@ApiProperty({ required: true })
@IsString()
username: string
@ApiProperty({ required: true })
@IsString()
password: string
}
export class UserPatchDto extends UserOptionDto {
@ApiProperty({ required: false })
@IsString()
@IsNotEmpty()
@IsOptional()
readonly username: string
@IsString()
@ApiProperty({ required: false })
@IsNotEmpty()
@IsOptional()
readonly password: string
}

View File

@@ -1,6 +1,93 @@
import { Controller } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
SerializeOptions,
UseGuards,
} from '@nestjs/common'
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'
import { CurrentUser } from '~/common/decorator/current-user.decorator'
import { IpLocation, IpRecord } from '~/common/decorator/ip.decorator'
import { AuthService } from '../auth/auth.service'
import { RolesGuard } from '../auth/roles.guard'
import { UserDocument, UserModel } from './user.model'
import { UserService } from './user.service'
import { IsMaster } from '~/common/decorator/role.decorator'
import { AuthGuard } from '@nestjs/passport'
import { getAvatar } from '~/utils/index.util'
import { LoginDto, UserDto, UserPatchDto } from './dto/user.dto'
@ApiTags('User Routes')
@Controller(['user', 'master'])
export class UserController {}
@Controller(['master', 'user'])
export class UserController {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Get()
@ApiOperation({ summary: '获取主人信息' })
@UseGuards(RolesGuard)
async getMasterInfo(@IsMaster() isMaster: boolean) {
return await this.userService.getMasterInfo(isMaster)
}
@Post('register')
@SerializeOptions({
excludePrefixes: ['password'],
})
@ApiOperation({ summary: '注册' })
async register(@Body() userDto: UserDto) {
userDto.name = userDto.name ?? userDto.username
return await this.userService.createMaster(userDto as UserModel)
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '登录' })
@UseGuards(AuthGuard('local'))
async login(
@Body() dto: LoginDto,
@CurrentUser() user: UserDocument,
@IpLocation() ipLocation: IpRecord,
) {
const footstep = await this.userService.recordFootstep(ipLocation.ip)
const { name, username, created, url, mail } = user
const avatar = user.avatar ?? getAvatar(mail)
return {
token: await this.authService.signToken(user._id),
...footstep,
name,
username,
created,
url,
mail,
avatar,
expiresIn: 7,
}
}
@Get('check_logged')
@ApiOperation({ summary: '判断当前 Token 是否有效 ' })
@ApiBearerAuth()
@UseGuards(RolesGuard)
checkLogged(@IsMaster() isMaster: boolean) {
return { ok: Number(isMaster), isGuest: !isMaster }
}
@Patch()
@ApiOperation({ summary: '修改主人的信息 ' })
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
async patchMasterData(
@Body() body: UserPatchDto,
@CurrentUser() user: UserDocument,
) {
return await this.userService.patchUserData(user, body)
}
}

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common'
import { AuthModule } from '../auth/auth.module'
import { UserController } from './user.controller'
import { UserService } from './user.service'
@Module({
controllers: [UserController],
providers: [UserService],
imports: [AuthModule],
exports: [UserService],
})
export class UserModule {}

View File

@@ -1,12 +1,129 @@
import { Injectable } from '@nestjs/common'
import {
BadRequestException,
Injectable,
Logger,
UnprocessableEntityException,
} from '@nestjs/common'
import { ReturnModelType } from '@typegoose/typegoose'
import { compareSync } from 'bcrypt'
import { nanoid } from 'nanoid'
import { InjectModel } from 'nestjs-typegoose'
import { UserModel } from './user.model'
import { getAvatar } from '~/utils/index.util'
import { AuthService } from '../auth/auth.service'
import { UserDocument, UserModel } from './user.model'
@Injectable()
export class UserService {
private Logger = new Logger(UserService.name)
constructor(
@InjectModel(UserModel)
private userModel: ReturnModelType<typeof UserModel>,
private readonly userModel: ReturnModelType<typeof UserModel>,
private readonly authService: AuthService,
) {}
async getMasterInfo(getLoginIp = false) {
const user = await this.userModel
.findOne()
.select('-authCode' + (getLoginIp ? ' +lastLoginIp' : ''))
.lean({ virtuals: true })
if (!user) {
throw new BadRequestException('没有完成初始化!')
}
const avatar = user.avatar ?? getAvatar(user.mail)
return { ...user, avatar }
}
async hasMaster() {
return !!(await this.userModel.countDocuments())
}
async createMaster(
model: Pick<UserModel, 'username' | 'name' | 'password'> &
Partial<Pick<UserModel, 'introduce' | 'avatar' | 'url'>>,
) {
const hasMaster = await this.hasMaster()
// 禁止注册两个以上账户
if (hasMaster) {
throw new BadRequestException('我已经有一个主人了哦')
}
const authCode = nanoid(10)
// @ts-ignore
const res = await this.userModel.create({ ...model, authCode })
const token = await this.authService.signToken(res._id)
return { token, username: res.username, authCode: res.authCode }
}
/**
* 修改密码
*
* @async
* @param {DocumentType} user - 用户查询结果, 已经挂载在 req.user
* @param {Partial} data - 部分修改数据
*/
async patchUserData(user: UserDocument, data: Partial<UserModel>) {
const { password } = data
const doc = { ...data }
if (password !== undefined) {
const { _id } = user
const currentUser = await this.userModel
.findById(_id)
.select('+password +apiToken')
// 1. 验证新旧密码是否一致
const isSamePassword = compareSync(password, currentUser.password)
if (isSamePassword) {
throw new UnprocessableEntityException('密码可不能和原来的一样哦')
}
// 2. 认证码重新生成
const newCode = nanoid(10)
doc.authCode = newCode
}
return await this.userModel
.updateOne({ _id: user._id }, doc)
.setOptions({ omitUndefined: true })
}
/**
* 记录登陆的足迹(ip, 时间)
*
* @async
* @param {string} ip - string
* @return {Promise<Record<string, Date|string>>} 返回上次足迹
*/
async recordFootstep(ip: string): Promise<Record<string, Date | string>> {
const master = await this.userModel.findOne()
const PrevFootstep = {
lastLoginTime: master.lastLoginTime || new Date(1586090559569),
lastLoginIp: master.lastLoginIp || null,
}
await master.updateOne({
lastLoginTime: new Date(),
lastLoginIp: ip,
})
// save to redis
new Promise(async (resolve) => {
// const redisClient = this.redisService.getClient(RedisNames.LoginRecord)
// const dateFormat = dayjs().format('YYYY-MM-DD')
// const value = JSON.parse(
// (await redisClient.get(dateFormat)) || '[]',
// ) as LoginRecord[]
// const stringify = fastJson({
// title: 'login-record schema',
// type: 'array',
// items: {
// type: 'object',
// properties: {
// ip: { type: 'string' },
// date: { type: 'string' },
// },
// },
// })
// await redisClient.set(
// dateFormat,
// stringify(value.concat({ date: new Date().toISOString(), ip })),
// )
// resolve(null)
})
this.Logger.warn('主人已登录, IP: ' + ip)
return PrevFootstep
}
}

View File

@@ -0,0 +1,17 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { Logger, LoggerService } from '@nestjs/common'
class LoggerModule implements LoggerService {
logger: Logger
constructor(context: string, options?: { timestamp?: boolean }) {
this.logger = new Logger(context, options)
}
debug(...message: any[]) {
// chalk.
// this.logger.debug(message)
}
error(...message: any[]) {}
log(...message: any[]) {}
verbose(...message: any[]) {}
warn(...message: any[]) {}
}

View File

@@ -1,3 +0,0 @@
export const isDev = process.env.NODE_ENV == 'development'
export * from './ip'

11
src/utils/index.util.ts Normal file
View File

@@ -0,0 +1,11 @@
export * from './ip.util'
export const isDev = process.env.NODE_ENV == 'development'
export const md5 = (text: string) =>
require('crypto').createHash('md5').update(text).digest('hex')
export function getAvatar(mail: string) {
if (!mail) {
return ''
}
return `https://sdn.geekzu.org/avatar/${md5(mail)}`
}

View File

@@ -24,3 +24,10 @@ export const getIp = (request: FastifyRequest | IncomingMessage) => {
}
return ip
}
export const parseRelativeUrl = (path: string) => {
if (!path || !path.startsWith('/')) {
return new URL('http://a.com')
}
return new URL('http://a.com' + path)
}