feat: test email is working

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2022-08-27 22:23:02 +08:00
parent a4410ec928
commit a2f2be346b
9 changed files with 307 additions and 239 deletions

View File

@@ -12,7 +12,7 @@ export const ApiController: (
}
const transformPath = (path: string) =>
`${apiRoutePrefix}/${path.replace(/\//, '')}`
`${apiRoutePrefix}/${path.replace(/^\/*/, '')}`
if (typeof controller === 'string') {
return Controller(transformPath(controller), ...args)

View File

@@ -6,11 +6,13 @@ import { BadRequestException, Get, Query, Sse } from '@nestjs/common'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { DATA_DIR } from '~/constants/path.constant'
import { installPKG } from '~/utils'
@ApiController('dependencies')
@Auth()
@ApiName
export class DependencyController {
constructor() {}

View File

@@ -1,247 +1,35 @@
import { FastifyReply } from 'fastify'
import { isFunction, isString } from 'lodash'
import { resolve } from 'path'
import { Readable } from 'stream'
import {
BadRequestException,
Delete,
Get,
Param,
Post,
Query,
Res,
UnprocessableEntityException,
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { SchedulerRegistry } from '@nestjs/schedule'
import { Get } from '@nestjs/common'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { BanInDemo } from '~/common/decorator/demo.decorator'
import { HttpCache } from '~/common/decorator/cache.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { CRON_DESCRIPTION } from '~/constants/meta.constant'
import { LOG_DIR } from '~/constants/path.constant'
import { SCHEDULE_CRON_OPTIONS } from '~/constants/system.constant'
import { CronService } from '~/processors/helper/helper.cron.service'
import { TaskQueueService } from '~/processors/helper/helper.tq.service'
import { formatByteSize } from '~/utils'
import { getTodayLogFilePath } from '~/utils/path.util'
import { EmailService } from '~/processors/helper/helper.email.service'
import { LogQueryDto, LogTypeDto } from './health.dto'
@ApiController({
path: 'health',
})
@Auth()
@ApiController('health')
@ApiName
export class HealthController {
constructor(
private schedulerRegistry: SchedulerRegistry,
private readonly cronService: CronService,
private readonly reflector: Reflector,
private readonly taskQueue: TaskQueueService,
) {}
constructor(private readonly emailService: EmailService) {}
@Get('/cron')
// 跳过 JSON 结构转换
@Get('/')
@HTTPDecorators.Bypass
async getAllCron() {
const cron = Object.getPrototypeOf(this.cronService)
const keys = Object.getOwnPropertyNames(cron).slice(1)
const map = {}
for (const key of keys) {
const method = cron[key]
if (!isFunction(method)) {
continue
}
const options = this.reflector.get(SCHEDULE_CRON_OPTIONS, method)
const description = this.reflector.get(CRON_DESCRIPTION, method) || ''
const job = this.schedulerRegistry.getCronJob(options.name)
map[key] = {
...options,
description,
lastDate: job?.lastDate() || null,
nextDate: job?.nextDate() || null,
status: job?.running ? 'running' : 'stopped',
}
}
return map
@HttpCache({
disable: true,
})
async check() {
// TODO
return 'OK'
}
@Post('/cron/run/:name')
@BanInDemo
async runCron(@Param('name') name: string) {
if (!isString(name)) {
throw new UnprocessableEntityException('name must be string')
}
const cron = Object.getPrototypeOf(this.cronService)
const keys = Object.getOwnPropertyNames(cron).slice(1)
const hasMethod = keys.find((key) => key === name)
if (!hasMethod) {
throw new BadRequestException(`${name} is not a cron`)
}
this.taskQueue.add(name, async () =>
this.cronService[name].call(this.cronService),
)
}
@Get('/cron/task/:name')
async getCronTaskStatus(@Param('name') name: string) {
if (!isString(name)) {
throw new BadRequestException('name must be string')
}
const task = await this.taskQueue.get(name)
if (!task) {
throw new BadRequestException(`${name} is not a cron in task queue`)
}
return task
}
@Get('/log/list/:type')
async getPM2List(@Param() params: LogTypeDto) {
const { type } = params
let logDir: string
switch (type) {
case 'native':
logDir = LOG_DIR
break
case 'pm2':
logDir = resolve(os.homedir(), '.pm2', 'logs')
break
}
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
const files = await fs.readdir(logDir)
const allFile = [] as string[]
switch (type) {
case 'pm2':
for (const file of files) {
if (file.startsWith('mx-server-') && file.endsWith('.log')) {
allFile.push(file)
}
}
break
case 'native':
allFile.push(...files)
break
}
const res = [] as {
size: string
filename: string
type: string
index: number
created: number
}[]
for (const [i, file] of Object.entries(allFile)) {
const stat = await fs.stat(path.join(logDir, file))
const byteSize = stat.size
const size = formatByteSize(byteSize)
let index: number
let _type: string
switch (type) {
case 'pm2':
_type = file.split('-')[2].split('.')[0]
index = parseInt(file.split('-')[3], 10) || 0
break
case 'native':
_type = 'log'
index = +i
break
@Get('/email/test')
@Auth()
async testEmail() {
return this.emailService.sendTestEmail().catch((err) => {
return {
message: err.message,
trace: err.stack,
}
res.push({
size,
filename: file,
index,
type: _type,
created: stat.ctimeMs,
})
}
return res.sort((a, b) => b.created - a.created)
}
@Get('/log/:type')
@HTTPDecorators.Bypass
async getLog(
@Query() query: LogQueryDto,
@Param() params: LogTypeDto,
@Res() reply: FastifyReply,
) {
const { type: logType } = params
let stream: Readable
switch (logType) {
case 'pm2': {
const { index, type = 'out', filename: __filename } = query
const logDir = resolve(os.homedir(), '.pm2', 'logs')
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
const filename =
__filename ?? `mx-server-${type}${index === 0 ? '' : `-${index}`}.log`
const logPath = path.join(logDir, filename)
if (!fs.existsSync(logPath)) {
throw new BadRequestException('log file not exists')
}
stream = fs.createReadStream(logPath, {
encoding: 'utf8',
})
break
}
case 'native': {
const { filename } = query
const logDir = LOG_DIR
if (!filename) {
throw new UnprocessableEntityException('filename must be string')
}
stream = fs.createReadStream(path.join(logDir, filename), {
encoding: 'utf-8',
})
break
}
}
reply.type('text/plain')
return reply.send(stream)
}
@Delete('/log/:type')
async deleteLog(@Param() params: LogTypeDto, @Query() query: LogQueryDto) {
const { type } = params
const { filename } = query
switch (type) {
case 'native': {
const logPath = path.join(LOG_DIR, filename)
const todayLogFile = getTodayLogFilePath()
if (logPath.endsWith('error.log') || todayLogFile === logPath) {
await fs.writeFile(logPath, '', { encoding: 'utf8', flag: 'w' })
break
}
await fs.rm(logPath)
break
}
case 'pm2': {
const logDir = resolve(os.homedir(), '.pm2', 'logs')
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
await fs.rm(path.join(logDir, filename))
break
}
}
})
}
}

View File

@@ -1,6 +1,10 @@
import { Module } from '@nestjs/common'
import { HealthController } from './health.controller'
import { HealthCronController } from './sub-controller/cron.controller'
import { HealthLogController } from './sub-controller/log.controller'
@Module({ controllers: [HealthController] })
@Module({
controllers: [HealthController, HealthCronController, HealthLogController],
})
export class HealthModule {}

View File

@@ -0,0 +1,90 @@
import { isString } from 'class-validator'
import { isFunction } from 'lodash'
import {
BadRequestException,
Get,
Param,
Post,
UnprocessableEntityException,
} from '@nestjs/common'
import { Reflector } from '@nestjs/core'
import { SchedulerRegistry } from '@nestjs/schedule'
import { SCHEDULE_CRON_OPTIONS } from '@nestjs/schedule/dist/schedule.constants'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { BanInDemo } from '~/common/decorator/demo.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { CRON_DESCRIPTION } from '~/constants/meta.constant'
import { CronService } from '~/processors/helper/helper.cron.service'
import { TaskQueueService } from '~/processors/helper/helper.tq.service'
@ApiController('health/cron')
@Auth()
@ApiName
export class HealthCronController {
constructor(
private schedulerRegistry: SchedulerRegistry,
private readonly cronService: CronService,
private readonly reflector: Reflector,
private readonly taskQueue: TaskQueueService,
) {}
@Get('/')
// 跳过 JSON 结构转换
@HTTPDecorators.Bypass
async getAllCron() {
const cron = Object.getPrototypeOf(this.cronService)
const keys = Object.getOwnPropertyNames(cron).slice(1)
const map = {}
for (const key of keys) {
const method = cron[key]
if (!isFunction(method)) {
continue
}
const options = this.reflector.get(SCHEDULE_CRON_OPTIONS, method)
const description = this.reflector.get(CRON_DESCRIPTION, method) || ''
const job = this.schedulerRegistry.getCronJob(options.name)
map[key] = {
...options,
description,
lastDate: job?.lastDate() || null,
nextDate: job?.nextDate() || null,
status: job?.running ? 'running' : 'stopped',
}
}
return map
}
@Post('/run/:name')
@BanInDemo
async runCron(@Param('name') name: string) {
if (!isString(name)) {
throw new UnprocessableEntityException('name must be string')
}
const cron = Object.getPrototypeOf(this.cronService)
const keys = Object.getOwnPropertyNames(cron).slice(1)
const hasMethod = keys.find((key) => key === name)
if (!hasMethod) {
throw new BadRequestException(`${name} is not a cron`)
}
this.taskQueue.add(name, async () =>
this.cronService[name].call(this.cronService),
)
}
@Get('/task/:name')
async getCronTaskStatus(@Param('name') name: string) {
if (!isString(name)) {
throw new BadRequestException('name must be string')
}
const task = await this.taskQueue.get(name)
if (!task) {
throw new BadRequestException(`${name} is not a cron in task queue`)
}
return task
}
}

View File

@@ -0,0 +1,171 @@
import { Readable } from 'form-data'
import {
BadRequestException,
Delete,
Get,
Param,
Query,
Res,
UnprocessableEntityException,
} from '@nestjs/common'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { LOG_DIR } from '~/constants/path.constant'
import { AdapterResponse } from '~/types/request'
import { formatByteSize } from '~/utils'
import { getTodayLogFilePath } from '~/utils/path.util'
import { LogQueryDto, LogTypeDto } from '../health.dto'
@ApiController('health/log')
@Auth()
@ApiName
export class HealthLogController {
@Get('/list/:type')
async getPM2List(@Param() params: LogTypeDto) {
const { type } = params
let logDir: string
switch (type) {
case 'native':
logDir = LOG_DIR
break
case 'pm2':
logDir = path.resolve(os.homedir(), '.pm2', 'logs')
break
}
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
const files = await fs.readdir(logDir)
const allFile = [] as string[]
switch (type) {
case 'pm2':
for (const file of files) {
if (file.startsWith('mx-server-') && file.endsWith('.log')) {
allFile.push(file)
}
}
break
case 'native':
allFile.push(...files)
break
}
const res = [] as {
size: string
filename: string
type: string
index: number
created: number
}[]
for (const [i, file] of Object.entries(allFile)) {
const stat = await fs.stat(path.join(logDir, file))
const byteSize = stat.size
const size = formatByteSize(byteSize)
let index: number
let _type: string
switch (type) {
case 'pm2':
_type = file.split('-')[2].split('.')[0]
index = parseInt(file.split('-')[3], 10) || 0
break
case 'native':
_type = 'log'
index = +i
break
}
res.push({
size,
filename: file,
index,
type: _type,
created: stat.ctimeMs,
})
}
return res.sort((a, b) => b.created - a.created)
}
@Get('/:type')
@HTTPDecorators.Bypass
async getLog(
@Query() query: LogQueryDto,
@Param() params: LogTypeDto,
@Res() reply: AdapterResponse,
) {
const { type: logType } = params
let stream: Readable
switch (logType) {
case 'pm2': {
const { index, type = 'out', filename: __filename } = query
const logDir = path.resolve(os.homedir(), '.pm2', 'logs')
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
const filename =
__filename ?? `mx-server-${type}${index === 0 ? '' : `-${index}`}.log`
const logPath = path.join(logDir, filename)
if (!fs.existsSync(logPath)) {
throw new BadRequestException('log file not exists')
}
stream = fs.createReadStream(logPath, {
encoding: 'utf8',
})
break
}
case 'native': {
const { filename } = query
const logDir = LOG_DIR
if (!filename) {
throw new UnprocessableEntityException('filename must be string')
}
stream = fs.createReadStream(path.join(logDir, filename), {
encoding: 'utf-8',
})
break
}
}
reply.type('text/plain')
return reply.send(stream)
}
@Delete('/:type')
async deleteLog(@Param() params: LogTypeDto, @Query() query: LogQueryDto) {
const { type } = params
const { filename } = query
switch (type) {
case 'native': {
const logPath = path.join(LOG_DIR, filename)
const todayLogFile = getTodayLogFilePath()
if (logPath.endsWith('error.log') || todayLogFile === logPath) {
await fs.writeFile(logPath, '', { encoding: 'utf8', flag: 'w' })
break
}
await fs.rm(logPath)
break
}
case 'pm2': {
const logDir = path.resolve(os.homedir(), '.pm2', 'logs')
if (!fs.pathExistsSync(logDir)) {
throw new BadRequestException('log dir not exists')
}
await fs.rm(path.join(logDir, filename))
break
}
}
}
}

View File

@@ -8,6 +8,7 @@ import { dashboard } from '~/../package.json'
import { ApiController } from '~/common/decorator/api-controller.decorator'
import { Auth } from '~/common/decorator/auth.decorator'
import { HTTPDecorators } from '~/common/decorator/http.decorator'
import { ApiName } from '~/common/decorator/openapi.decorator'
import { LOCAL_ADMIN_ASSET_PATH } from '~/constants/path.constant'
import { UpdateAdminDto } from './update.dto'
@@ -15,6 +16,7 @@ import { UpdateService } from './update.service'
@ApiController('update')
@Auth()
@ApiName
export class UpdateController {
constructor(private readonly service: UpdateService) {}

View File

@@ -117,8 +117,8 @@ export class EmailService {
this.configsService.waitForConfigReady().then(({ mailOptions }) => {
const { options, user, pass } = mailOptions
if (!user && !pass) {
const message = '邮件件客户端未认证'
this.logger.error(message)
const message = '未启动邮件通知'
this.logger.warn(message)
return j(message)
}
// @ts-ignore
@@ -137,7 +137,7 @@ export class EmailService {
// 验证有效性
private verifyClient() {
return new Promise<boolean>((r, j) => {
return new Promise<boolean>((r) => {
this.instance.verify((error) => {
if (error) {
this.logger.error('邮件客户端初始化连接失败!')
@@ -211,6 +211,17 @@ export class EmailService {
} as EmailTemplateRenderProps)
}
async sendTestEmail() {
const master = await this.configsService.getMaster()
const mailOptons = await this.configsService.get('mailOptions')
return this.instance.sendMail({
from: `"Mx Space" <${mailOptons.user}>`,
to: master.mail,
subject: '测试邮件',
text: '这是一封测试邮件',
})
}
getInstance() {
return this.instance
}

View File

@@ -2,7 +2,7 @@ import { FastifyReply, FastifyRequest } from 'fastify'
import { UserModel } from '~/modules/user/user.model'
export type Request = FastifyRequest &
export type AdapterRequest = FastifyRequest &
(
| {
isGuest: true
@@ -16,4 +16,4 @@ export type Request = FastifyRequest &
}
) &
Record<string, any>
export type Response = FastifyReply & Record<string, any>
export type AdapterResponse = FastifyReply & Record<string, any>