From a2f2be346ba913cbcfcbfc00354accc1609b7a8f Mon Sep 17 00:00:00 2001 From: Innei Date: Sat, 27 Aug 2022 22:23:02 +0800 Subject: [PATCH] feat: test email is working Signed-off-by: Innei --- .../decorator/api-controller.decorator.ts | 2 +- .../dependency/dependency.controller.ts | 2 + src/modules/health/health.controller.ts | 252 ++---------------- src/modules/health/health.module.ts | 6 +- .../health/sub-controller/cron.controller.ts | 90 +++++++ .../health/sub-controller/log.controller.ts | 171 ++++++++++++ src/modules/update/update.controller.ts | 2 + src/processors/helper/helper.email.service.ts | 17 +- src/types/request.ts | 4 +- 9 files changed, 307 insertions(+), 239 deletions(-) create mode 100644 src/modules/health/sub-controller/cron.controller.ts create mode 100644 src/modules/health/sub-controller/log.controller.ts diff --git a/src/common/decorator/api-controller.decorator.ts b/src/common/decorator/api-controller.decorator.ts index d18c7b06..5c071db8 100644 --- a/src/common/decorator/api-controller.decorator.ts +++ b/src/common/decorator/api-controller.decorator.ts @@ -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) diff --git a/src/modules/dependency/dependency.controller.ts b/src/modules/dependency/dependency.controller.ts index 2230e3cb..6168d128 100644 --- a/src/modules/dependency/dependency.controller.ts +++ b/src/modules/dependency/dependency.controller.ts @@ -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() {} diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts index a7d866d7..38938a57 100644 --- a/src/modules/health/health.controller.ts +++ b/src/modules/health/health.controller.ts @@ -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 - } - } + }) } } diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts index a6ce1ae7..7dab3870 100644 --- a/src/modules/health/health.module.ts +++ b/src/modules/health/health.module.ts @@ -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 {} diff --git a/src/modules/health/sub-controller/cron.controller.ts b/src/modules/health/sub-controller/cron.controller.ts new file mode 100644 index 00000000..e9adbbec --- /dev/null +++ b/src/modules/health/sub-controller/cron.controller.ts @@ -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 + } +} diff --git a/src/modules/health/sub-controller/log.controller.ts b/src/modules/health/sub-controller/log.controller.ts new file mode 100644 index 00000000..02b54e31 --- /dev/null +++ b/src/modules/health/sub-controller/log.controller.ts @@ -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 + } + } + } +} diff --git a/src/modules/update/update.controller.ts b/src/modules/update/update.controller.ts index a9d538f0..ca8a0cf0 100644 --- a/src/modules/update/update.controller.ts +++ b/src/modules/update/update.controller.ts @@ -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) {} diff --git a/src/processors/helper/helper.email.service.ts b/src/processors/helper/helper.email.service.ts index 1b46c101..82d1b580 100644 --- a/src/processors/helper/helper.email.service.ts +++ b/src/processors/helper/helper.email.service.ts @@ -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((r, j) => { + return new Promise((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 } diff --git a/src/types/request.ts b/src/types/request.ts index a39fcc2f..81e76615 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -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 -export type Response = FastifyReply & Record +export type AdapterResponse = FastifyReply & Record