From d3f64707a0218f6d41b2f5496ec757966e163294 Mon Sep 17 00:00:00 2001 From: luch <32321690+luch1994@users.noreply.github.com> Date: Thu, 25 Apr 2024 13:44:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9Efile=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/.env | 2 +- server/package.json | 5 + server/src/app.module.ts | 15 ++- server/src/enums/exceptionCode.ts | 4 +- server/src/exceptions/authException.ts | 4 +- server/src/guards/authtication.spec.ts | 8 +- server/src/guards/authtication.ts | 29 ++--- server/src/modules/auth/auth.module.ts | 2 +- .../src/modules/auth/services/auth.service.ts | 25 +++- server/src/modules/file/config/index.ts | 62 ++++++++++ .../file/controllers/file.controller.ts | 111 ++++++++++++++++++ server/src/modules/file/file.module.ts | 26 ++++ .../src/modules/file/services/file.service.ts | 102 ++++++++++++++++ .../services/uploadHandlers/alioss.handler.ts | 65 ++++++++++ .../services/uploadHandlers/local.handler.ts | 40 +++++++ .../services/uploadHandlers/minio.handler.ts | 79 +++++++++++++ .../services/uploadHandlers/qiniu.handler.ts | 93 +++++++++++++++ .../uploadHandlers/uploadHandler.interface.ts | 7 ++ .../file/utils/generateUniqueFilename.ts | 11 ++ .../file/utils/parseExpiryTimeToSeconds.ts | 13 ++ .../src/modules/file/utils/replaceFileKey.ts | 12 ++ ...agePushing.module.ts => message.module.ts} | 2 +- .../surveyResponse/surveyResponse.module.ts | 4 +- 23 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 server/src/modules/file/config/index.ts create mode 100644 server/src/modules/file/controllers/file.controller.ts create mode 100644 server/src/modules/file/file.module.ts create mode 100644 server/src/modules/file/services/file.service.ts create mode 100644 server/src/modules/file/services/uploadHandlers/alioss.handler.ts create mode 100644 server/src/modules/file/services/uploadHandlers/local.handler.ts create mode 100644 server/src/modules/file/services/uploadHandlers/minio.handler.ts create mode 100644 server/src/modules/file/services/uploadHandlers/qiniu.handler.ts create mode 100644 server/src/modules/file/services/uploadHandlers/uploadHandler.interface.ts create mode 100644 server/src/modules/file/utils/generateUniqueFilename.ts create mode 100644 server/src/modules/file/utils/parseExpiryTimeToSeconds.ts create mode 100644 server/src/modules/file/utils/replaceFileKey.ts rename server/src/modules/message/{messagePushing.module.ts => message.module.ts} (96%) diff --git a/server/.env b/server/.env index c039a348..3a0589b4 100644 --- a/server/.env +++ b/server/.env @@ -9,4 +9,4 @@ XIAOJU_SURVEY_HTTP_DATA_ENCRYPT_TYPE=rsa XIAOJU_SURVEY_JWT_SECRET=xiaojuSurveyJwtSecret XIAOJU_SURVEY_JWT_EXPIRES_IN=8h -XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log \ No newline at end of file +XIAOJU_SURVEY_LOGGER_FILENAME=./logs/app.log diff --git a/server/package.json b/server/package.json index 4123bdd2..85273956 100644 --- a/server/package.json +++ b/server/package.json @@ -26,18 +26,22 @@ "@nestjs/serve-static": "^4.0.0", "@nestjs/swagger": "^7.3.0", "@nestjs/typeorm": "^10.0.1", + "ali-oss": "^6.20.0", "cheerio": "^1.0.0-rc.12", "crypto-js": "^4.2.0", "dotenv": "^16.3.2", + "fs-extra": "^11.2.0", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", "lodash": "^4.17.21", "log4js": "^6.9.1", + "minio": "^7.1.3", "moment": "^2.30.1", "mongodb": "^5.9.2", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", "node-forge": "^1.3.1", + "qiniu": "^7.11.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "svg-captcha": "^1.4.0", @@ -49,6 +53,7 @@ "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/node-forge": "^1.3.11", "@types/supertest": "^2.0.12", diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 5137aa10..4950f80a 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -11,6 +11,8 @@ import { ServeStaticModule } from '@nestjs/serve-static'; import { SurveyModule } from './modules/survey/survey.module'; import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.module'; import { AuthModule } from './modules/auth/auth.module'; +import { MessageModule } from './modules/message/message.module'; +import { FileModule } from './modules/file/file.module'; import { join } from 'path'; @@ -39,7 +41,6 @@ import { Logger } from './logger'; @Module({ imports: [ ConfigModule.forRoot({}), - TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -80,9 +81,17 @@ import { Logger } from './logger'; AuthModule, SurveyModule, SurveyResponseModule, - ServeStaticModule.forRoot({ - rootPath: join(__dirname, '..', 'public'), + ServeStaticModule.forRootAsync({ + useFactory: async () => { + return [ + { + rootPath: join(__dirname, '..', 'public'), + }, + ]; + }, }), + MessageModule, + FileModule, ], controllers: [AppController], providers: [ diff --git a/server/src/enums/exceptionCode.ts b/server/src/enums/exceptionCode.ts index f051c6d8..cb15614b 100644 --- a/server/src/enums/exceptionCode.ts +++ b/server/src/enums/exceptionCode.ts @@ -1,5 +1,5 @@ export enum EXCEPTION_CODE { - AUTHTIFICATION_FAILED = 1001, // 没有权限 + AUTHENTICATION_FAILED = 1001, // 没有权限 PARAMETER_ERROR = 1002, // 参数有误 USER_EXISTS = 2001, // 用户已存在 USER_NOT_EXISTS = 2002, // 用户不存在 @@ -15,4 +15,6 @@ export enum EXCEPTION_CODE { RESPONSE_OVER_LIMIT = 9003, // 超出限制 RESPONSE_SCHEMA_REMOVED = 9004, // 问卷已删除 RESPONSE_DATA_DECRYPT_ERROR = 9005, // 问卷已删除 + + UPLOAD_FILE_ERROR = 5001, // 上传文件错误 } diff --git a/server/src/exceptions/authException.ts b/server/src/exceptions/authException.ts index 9ec67ea6..e9793095 100644 --- a/server/src/exceptions/authException.ts +++ b/server/src/exceptions/authException.ts @@ -1,7 +1,7 @@ import { HttpException } from './httpException'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; -export class AuthtificationException extends HttpException { +export class AuthenticationException extends HttpException { constructor(public readonly message: string) { - super(message, EXCEPTION_CODE.AUTHTIFICATION_FAILED); + super(message, EXCEPTION_CODE.AUTHENTICATION_FAILED); } } diff --git a/server/src/guards/authtication.spec.ts b/server/src/guards/authtication.spec.ts index b5a0e981..125589b9 100644 --- a/server/src/guards/authtication.spec.ts +++ b/server/src/guards/authtication.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Authtication } from './authtication'; import { UserService } from '../modules/auth/services/user.service'; import { ConfigService } from '@nestjs/config'; -import { AuthtificationException } from '../exceptions/authException'; +import { AuthenticationException } from '../exceptions/authException'; import { User } from 'src/models/user.entity'; import * as jwt from 'jsonwebtoken'; @@ -47,7 +47,7 @@ describe('Authtication', () => { }; await expect(guard.canActivate(context as any)).rejects.toThrow( - AuthtificationException, + AuthenticationException, ); }); @@ -69,7 +69,7 @@ describe('Authtication', () => { .mockReturnValue('XIAOJU_SURVEY_JWT_SECRET'); await expect(guard.canActivate(context as any)).rejects.toThrow( - AuthtificationException, + AuthenticationException, ); }); @@ -94,7 +94,7 @@ describe('Authtication', () => { jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(null); await expect(guard.canActivate(context as any)).rejects.toThrow( - AuthtificationException, + AuthenticationException, ); }); diff --git a/server/src/guards/authtication.ts b/server/src/guards/authtication.ts index 886a8814..41c384a6 100644 --- a/server/src/guards/authtication.ts +++ b/server/src/guards/authtication.ts @@ -1,14 +1,13 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { UserService } from '../modules/auth/services/user.service'; -import { verify } from 'jsonwebtoken'; -import { ConfigService } from '@nestjs/config'; -import { AuthtificationException } from '../exceptions/authException'; +import { AuthenticationException } from '../exceptions/authException'; +import { AuthService } from 'src/modules/auth/services/auth.service'; @Injectable() export class Authtication implements CanActivate { constructor( private readonly userService: UserService, - private readonly configService: ConfigService, + private readonly authService: AuthService, ) {} async canActivate(context: ExecutionContext): Promise { @@ -16,25 +15,15 @@ export class Authtication implements CanActivate { const token = request.headers.authorization?.split(' ')[1]; if (!token) { - throw new AuthtificationException('请登录'); + throw new AuthenticationException('请登录'); } - let decoded; try { - decoded = verify( - token, - this.configService.get('XIAOJU_SURVEY_JWT_SECRET'), - ); - } catch (err) { - throw new AuthtificationException('用户凭证错误'); + const user = await this.authService.verifyToken(token); + request.user = user; + return true; + } catch (error) { + throw new AuthenticationException(error?.message || '用户凭证错误'); } - const user = await this.userService.getUserByUsername(decoded.username); // 从数据库中查找用户 - - if (!user) { - throw new AuthtificationException('用户不存在'); - } - - request.user = user; // 将用户信息存储在请求中 - return true; } } diff --git a/server/src/modules/auth/auth.module.ts b/server/src/modules/auth/auth.module.ts index 8557584a..ffa6e3e8 100644 --- a/server/src/modules/auth/auth.module.ts +++ b/server/src/modules/auth/auth.module.ts @@ -15,6 +15,6 @@ import { ConfigModule } from '@nestjs/config'; imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], controllers: [AuthController], providers: [UserService, AuthService, CaptchaService], - exports: [UserService], + exports: [UserService, AuthService], }) export class AuthModule {} diff --git a/server/src/modules/auth/services/auth.service.ts b/server/src/modules/auth/services/auth.service.ts index be750fe2..059119c4 100644 --- a/server/src/modules/auth/services/auth.service.ts +++ b/server/src/modules/auth/services/auth.service.ts @@ -1,9 +1,15 @@ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; -import { sign } from 'jsonwebtoken'; +import { sign, verify } from 'jsonwebtoken'; +import { UserService } from './user.service'; @Injectable() export class AuthService { + constructor( + private readonly configService: ConfigService, + private readonly userService: UserService, + ) {} async generateToken( { _id, username }: { _id: string; username: string }, { secret, expiresIn }: { secret: string; expiresIn: string }, @@ -12,4 +18,21 @@ export class AuthService { expiresIn, }); } + + async verifyToken(token: string) { + let decoded; + try { + decoded = verify( + token, + this.configService.get('XIAOJU_SURVEY_JWT_SECRET'), + ); + } catch (err) { + throw new Error('用户凭证错误'); + } + const user = await this.userService.getUserByUsername(decoded.username); + if (!user) { + throw new Error('用户不存在'); + } + return user; + } } diff --git a/server/src/modules/file/config/index.ts b/server/src/modules/file/config/index.ts new file mode 100644 index 00000000..75e4b6e2 --- /dev/null +++ b/server/src/modules/file/config/index.ts @@ -0,0 +1,62 @@ +const SERVER_LOCAL_CONFIG = { + LOCAL_STATIC_RENDER_TYPE: 'server', // nginx + IS_PRIVATE_READ: false, + FILE_KEY_PREFIX: 'userUpload', // 存储路径 + NEED_AUTH: true, +}; + +const QINIU_CONFIG = { + FILE_STORAGE_PROVIDER: 'qiniu', + IS_PRIVATE_READ: false, + FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀,会根据此处配置校验body的参数 + NEED_AUTH: true, // 是否需要登录 + LINK_EXPIRY_TIME: '2h', + + // minio、oss或者七牛云配置 + ACCESS_KEY: '', // your_access_key + SECRET_KEY: '', // your_secret_key + BUCKET: '', // your_bucket + ENDPOINT: '', // endpoint + USE_SSL: false, // useSSL +}; + +const ALI_OSS_CONFIG = { + FILE_STORAGE_PROVIDER: 'ali-oss', + IS_PRIVATE_READ: false, + FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀,会根据此处配置校验body的参数 + NEED_AUTH: true, // 是否需要登录 + LINK_EXPIRY_TIME: '2h', + + ACCESS_KEY: '', // your_access_key + SECRET_KEY: '', // your_secret_key + BUCKET: '', // your_bucket + REGION: '', + ENDPOINT: '', // endpoint + USE_SSL: false, // useSSL +}; + +export const MINIO_CONFIG = { + FILE_STORAGE_PROVIDER: 'minio', + IS_PRIVATE_READ: false, + FILE_KEY_PREFIX: 'userUpload/{surveyPath}', // 文件key的前缀,会根据此处配置校验body的参数 + NEED_AUTH: true, // 是否需要登录 + LINK_EXPIRY_TIME: '2h', + + ACCESS_KEY: '', // your_access_key + SECRET_KEY: '', // your_secret_key + BUCKET: '', // your_bucket + REGION: '', + ENDPOINT: '', // endpoint + USE_SSL: true, // useSSL +}; + +export const channels = { + upload: 'SERVER_LOCAL_CONFIG', +}; + +export const uploadConfig = { + SERVER_LOCAL_CONFIG, + QINIU_CONFIG, + ALI_OSS_CONFIG, + MINIO_CONFIG, +}; diff --git a/server/src/modules/file/controllers/file.controller.ts b/server/src/modules/file/controllers/file.controller.ts new file mode 100644 index 00000000..349a70ce --- /dev/null +++ b/server/src/modules/file/controllers/file.controller.ts @@ -0,0 +1,111 @@ +import { + Controller, + Post, + UploadedFile, + UseInterceptors, + HttpCode, + Request, + Body, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; + +import { FileService } from '../services/file.service'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; +import { AuthService } from 'src/modules/auth/services/auth.service'; +import { AuthenticationException } from 'src/exceptions/authException'; +import { ConfigService } from '@nestjs/config'; + +@Controller('/api/file') +export class FileController { + constructor( + private readonly fileService: FileService, + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + private getPathPrefix(fileKeyPrefix: string, data: Record) { + const regex = /\{([^}]*)\}/g; + let matches; + const keys = []; + while ((matches = regex.exec(fileKeyPrefix)) !== null) { + keys.push(matches[1]); + } + let result = fileKeyPrefix; + for (const key of keys) { + if (!data[key]) { + throw new HttpException( + `参数有误:${data[key]}`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + result = result.replace(new RegExp(`{${key}}`), data[key]); + } + return result; + } + + @Post('upload') + @HttpCode(200) + @UseInterceptors(FileInterceptor('file')) + async upload( + @UploadedFile() file: Express.Multer.File, + @Request() req, + @Body() reqBody, + ) { + const { channel } = reqBody; + + if (!channel || !this.configService.get(channel)) { + throw new HttpException( + `参数有误:${channel}`, + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const configKey = this.configService.get(channel); + const needAuth = this.configService.get(configKey); + if (needAuth) { + const token = req.headers.authorization?.split(' ')[1]; + + if (!token) { + throw new AuthenticationException('请登录'); + } + await this.authService.verifyToken(token); + } + const fileKeyPrefix = this.configService.get( + `${configKey}.FILE_KEY_PREFIX`, + ); + + const { key, url } = await this.fileService.upload({ + configKey, + file, + pathPrefix: fileKeyPrefix, + }); + return { + code: 200, + data: { + url, + key, + }, + }; + } + + @Post('getUrl') + @HttpCode(200) + async generateGetUrl(@Body() reqBody) { + const { channel, key } = reqBody; + if (!channel || !key || !this.configService.get(channel)) { + throw new HttpException( + '参数有误,请检查channel、key', + EXCEPTION_CODE.PARAMETER_ERROR, + ); + } + const configKey = this.configService.get(channel); + const url = this.fileService.getUrl({ configKey, key }); + return { + code: 200, + data: { + key, + url, + }, + }; + } +} diff --git a/server/src/modules/file/file.module.ts b/server/src/modules/file/file.module.ts new file mode 100644 index 00000000..fb88c135 --- /dev/null +++ b/server/src/modules/file/file.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; + +import { AuthModule } from '../auth/auth.module'; + +import { FileService } from './services/file.service'; + +import { FileController } from './controllers/file.controller'; + +import { uploadConfig, channels } from './config/index'; + +@Module({ + imports: [ + // 管理端和渲染端分开配置,因为管理端上传的内容一般需要公开,渲染端上传的内容一般需要限制访问,防止被当作图床 + ConfigModule.forFeature(() => { + return { + ...channels, + ...uploadConfig, + }; + }), + AuthModule, + ], + controllers: [FileController], + providers: [FileService], +}) +export class FileModule {} diff --git a/server/src/modules/file/services/file.service.ts b/server/src/modules/file/services/file.service.ts new file mode 100644 index 00000000..b499b3b1 --- /dev/null +++ b/server/src/modules/file/services/file.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +import { LocalHandler } from './uploadHandlers/local.handler'; +// import { QiniuHandler } from './uploadHandlers/qiniu.handler'; +// import { AliOssHandler } from './uploadHandlers/alioss.handler'; +// import { MinIOHandler } from './uploadHandlers/minio.handler'; + +@Injectable() +export class FileService { + constructor(private readonly configService: ConfigService) {} + + private getConfig(channel, key) { + return this.configService.get(channel + '_' + key); + } + + async upload({ + configKey, + file, + pathPrefix, + }: { + configKey: string; + file: Express.Multer.File; + pathPrefix: string; + }) { + const handler = this.getHandler(configKey); + const { key } = await handler.upload(file, { pathPrefix }); + const url = await handler.getUrl(key); + return { + key, + url, + }; + } + + getUrl({ configKey, key }) { + const handler = this.getHandler(configKey); + return handler.getUrl(key); + } + + private getHandler(configKey: string) { + const staticType = this.configService.get( + `${configKey}.LOCAL_STATIC_RENDER_TYPE`, + ); + let physicalRootPath; + if (staticType === 'nginx') { + physicalRootPath = this.configService.get( + `${configKey}.NGINX_STATIC_PATH`, + ); + } + if (!physicalRootPath) { + physicalRootPath = 'public'; + } + return new LocalHandler({ physicalRootPath }); + + // qiniu + // return new QiniuHandler({ + // accessKey: this.configService.get(`${configKey}.ACCESS_KEY`), + // secretKey: this.configService.get(`${configKey}.SECRET_KEY`), + // bucket: this.configService.get(`${configKey}.BUCKET`), + // endPoint: this.configService.get(`${configKey}.ENDPOINT`), + // useSSL: this.configService.get(`${configKey}.USE_SSL`), + // isPrivateRead: this.configService.get( + // `${configKey}.IS_PRIVATE_READ`, + // ), + // expiryTime: this.configService.get( + // `${configKey}.LINK_EXPIRY_TIME`, + // ), + // }); + + // ali-oss + // return new AliOssHandler({ + // accessKey: this.configService.get(`${configKey}.ACCESS_KEY`), + // secretKey: this.configService.get(`${configKey}.SECRET_KEY`), + // bucket: this.configService.get(`${configKey}.BUCKET`), + // endPoint: this.configService.get(`${configKey}.ENDPOINT`), + // useSSL: this.configService.get(`${configKey}.USE_SSL`), + // isPrivateRead: this.configService.get( + // `${configKey}.IS_PRIVATE_READ`, + // ), + // expiryTime: this.configService.get( + // `${configKey}.LINK_EXPIRY_TIME`, + // ), + // region: this.configService.get(`${configKey}.REGION`), + // }); + + // minio + // return new MinIOHandler({ + // accessKey: this.configService.get(`${configKey}.ACCESS_KEY`), + // secretKey: this.configService.get(`${configKey}.SECRET_KEY`), + // bucket: this.configService.get(`${configKey}.BUCKET`), + // endPoint: this.configService.get(`${configKey}.ENDPOINT`), + // useSSL: this.configService.get(`${configKey}.USE_SSL`), + // isPrivateRead: this.configService.get( + // `${configKey}.IS_PRIVATE_READ`, + // ), + // expiryTime: this.configService.get( + // `${configKey}.LINK_EXPIRY_TIME`, + // ), + // region: this.configService.get(`${configKey}.REGION`), + // }); + } +} diff --git a/server/src/modules/file/services/uploadHandlers/alioss.handler.ts b/server/src/modules/file/services/uploadHandlers/alioss.handler.ts new file mode 100644 index 00000000..0703f9d6 --- /dev/null +++ b/server/src/modules/file/services/uploadHandlers/alioss.handler.ts @@ -0,0 +1,65 @@ +import OSS from 'ali-oss'; +import { generateUniqueFilename } from '../../utils/generateUniqueFilename'; +import { join } from 'path'; +import { parseExpiryTimeToSeconds } from '../../utils/parseExpiryTimeToSeconds'; +import { FileUploadHandler } from './uploadHandler.interface'; + +export class AliOssHandler implements FileUploadHandler { + private client: OSS; + endPoint: string; + useSSL: boolean; + isPrivateRead: boolean; + expiryTime: string; + constructor({ + accessKey, + secretKey, + bucket, + region, + endPoint, + useSSL, + isPrivateRead, + expiryTime, + }) { + const client = new OSS({ + region, + accessKeyId: accessKey, + accessKeySecret: secretKey, + bucket, + }); + this.client = client; + this.endPoint = endPoint; + this.useSSL = useSSL; + this.isPrivateRead = isPrivateRead; + this.expiryTime = expiryTime; + } + + async upload( + file: Express.Multer.File, + options?: { + pathPrefix?: string; + }, + ): Promise<{ key: string }> { + const { pathPrefix } = options || {}; + const key = join( + pathPrefix || '', + await generateUniqueFilename(file.originalname), + ); + + await this.client.put(key, file.buffer); + + return { key }; + } + + getUrl(key: string): string { + const expireTimeSeconds = parseExpiryTimeToSeconds(this.expiryTime); + if (this.isPrivateRead) { + const url = this.client.signatureUrl(key, { + expires: expireTimeSeconds, + method: 'GET', + }); + return url; + } else { + return `${this.useSSL ? 'https' : 'http'}://${this.endPoint}/${key}`; + } + } +} diff --git a/server/src/modules/file/services/uploadHandlers/local.handler.ts b/server/src/modules/file/services/uploadHandlers/local.handler.ts new file mode 100644 index 00000000..122e7dcb --- /dev/null +++ b/server/src/modules/file/services/uploadHandlers/local.handler.ts @@ -0,0 +1,40 @@ +import { join, dirname } from 'path'; +import fse from 'fs-extra'; +import { createWriteStream } from 'fs'; +import { FileUploadHandler } from './uploadHandler.interface'; +import { generateUniqueFilename } from '../../utils/generateUniqueFilename'; + +export class LocalHandler implements FileUploadHandler { + private physicalRootPath: string; + constructor({ physicalRootPath }: { physicalRootPath: string }) { + this.physicalRootPath = physicalRootPath; + } + + async upload( + file: Express.Multer.File, + options?: { pathPrefix?: string }, + ): Promise<{ key: string }> { + const filename = await generateUniqueFilename(file.originalname); + const filePath = join( + options?.pathPrefix ? options?.pathPrefix : '', + filename, + ); + const physicalPath = join(this.physicalRootPath, filePath); + await fse.mkdir(dirname(physicalPath), { recursive: true }); + const writeStream = createWriteStream(physicalPath); + return new Promise((resolve, reject) => { + writeStream.on('finish', () => + resolve({ + key: filePath, + }), + ); + writeStream.on('error', reject); + writeStream.write(file.buffer); + writeStream.end(); + }); + } + + getUrl(key: string): string { + return `/${key}`; + } +} diff --git a/server/src/modules/file/services/uploadHandlers/minio.handler.ts b/server/src/modules/file/services/uploadHandlers/minio.handler.ts new file mode 100644 index 00000000..1891c1f6 --- /dev/null +++ b/server/src/modules/file/services/uploadHandlers/minio.handler.ts @@ -0,0 +1,79 @@ +import { Client } from 'minio'; +import { generateUniqueFilename } from '../../utils/generateUniqueFilename'; +import { join } from 'path'; +import { parseExpiryTimeToSeconds } from '../../utils/parseExpiryTimeToSeconds'; +import { FileUploadHandler } from './uploadHandler.interface'; +import { HttpException } from 'src/exceptions/httpException'; +import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; + +export class MinIOHandler implements FileUploadHandler { + private client: Client; + endPoint: string; + useSSL: boolean; + isPrivateRead: boolean; + expiryTime: string; + bucket: string; + constructor({ + accessKey, + secretKey, + bucket, + region, + endPoint, + useSSL, + isPrivateRead, + expiryTime, + }) { + const client = new Client({ + endPoint, + accessKey, + secretKey, + region, + useSSL, + pathStyle: true, + }); + this.client = client; + this.endPoint = endPoint; + this.useSSL = useSSL; + this.isPrivateRead = isPrivateRead; + this.expiryTime = expiryTime; + this.bucket = bucket; + } + + async upload( + file: Express.Multer.File, + options?: { + pathPrefix?: string; + }, + ): Promise<{ key: string }> { + const { pathPrefix } = options || {}; + const key = join( + pathPrefix || '', + await generateUniqueFilename(file.originalname), + ); + + try { + await this.client.putObject(this.bucket, key, file.buffer); + + return { key }; + } catch (error) { + throw new HttpException( + error.message || error.stack || '上传失败', + EXCEPTION_CODE.UPLOAD_FILE_ERROR, + ); + } + } + + async getUrl(key: string): Promise { + const expireTimeSeconds = parseExpiryTimeToSeconds(this.expiryTime); + + if (this.isPrivateRead) { + const url = await this.client.presignedGetObject( + this.bucket, + key, + expireTimeSeconds, + ); + return url; + } + return `${this.useSSL ? 'https' : 'http'}://${this.endPoint}/${this.bucket}/${key}`; + } +} diff --git a/server/src/modules/file/services/uploadHandlers/qiniu.handler.ts b/server/src/modules/file/services/uploadHandlers/qiniu.handler.ts new file mode 100644 index 00000000..bab98ced --- /dev/null +++ b/server/src/modules/file/services/uploadHandlers/qiniu.handler.ts @@ -0,0 +1,93 @@ +import qiniu from 'qiniu'; +import { join } from 'path'; +import { generateUniqueFilename } from '../../utils/generateUniqueFilename'; +import { parseExpiryTimeToSeconds } from '../../utils/parseExpiryTimeToSeconds'; +import { FileUploadHandler } from './uploadHandler.interface'; + +export class QiniuHandler implements FileUploadHandler { + private bucket: string; + private endPoint: string; + private useSSL: boolean; + private isPrivateRead: boolean; + private expiryTime: string; + private mac: qiniu.auth.digest.Mac; + + constructor({ + accessKey, + secretKey, + bucket, + endPoint, + useSSL, + isPrivateRead, + expiryTime, + }) { + this.bucket = bucket; + this.endPoint = endPoint; + this.useSSL = useSSL; + this.isPrivateRead = isPrivateRead; + this.expiryTime = expiryTime; + + const mac = new qiniu.auth.digest.Mac(accessKey, secretKey); + this.mac = mac; + } + + async upload( + file: Express.Multer.File, + options?: { pathPrefix?: string }, + ): Promise<{ key: string }> { + const config = new qiniu.conf.Config(); + const formUploader = new qiniu.form_up.FormUploader(config); + const putExtra = new qiniu.form_up.PutExtra(); + const key = join( + options?.pathPrefix ? options?.pathPrefix : '', + await generateUniqueFilename(file.originalname), + ); + + const putPolicy = new qiniu.rs.PutPolicy({ + scope: this.bucket + ':' + key, + }); + + const uploadToken = putPolicy.uploadToken(this.mac); + + return new Promise<{ key: string }>((resolve, reject) => { + formUploader.put( + uploadToken, + key, + file.buffer, + putExtra, + (respErr, respBody, respInfo) => { + if (respErr) { + reject(respErr); + } + if (respInfo.statusCode === 200) { + resolve({ key }); + } else { + reject(respBody); + } + }, + ); + }); + } + + getUrl(key: string): string { + if (!this.isPrivateRead) { + return `${this.useSSL ? 'https' : 'http'}://${this.endPoint}/${key}`; + } + + const config = new qiniu.conf.Config({ + useHttpsDomain: this.useSSL, + }); + const bucketManager = new qiniu.rs.BucketManager(this.mac, config); + let url; + if (this.isPrivateRead) { + const deadline = + Math.floor(Date.now() / 1000) + + parseExpiryTimeToSeconds(this.expiryTime); + url = bucketManager.privateDownloadUrl(this.endPoint, key, deadline); + } else { + url = bucketManager.publicDownloadUrl(this.endPoint, key); + } + + return this.useSSL ? `https://${url}` : `http://${url}`; + } +} diff --git a/server/src/modules/file/services/uploadHandlers/uploadHandler.interface.ts b/server/src/modules/file/services/uploadHandlers/uploadHandler.interface.ts new file mode 100644 index 00000000..490214e5 --- /dev/null +++ b/server/src/modules/file/services/uploadHandlers/uploadHandler.interface.ts @@ -0,0 +1,7 @@ +export interface FileUploadHandler { + upload( + file: Express.Multer.File, + options?: { pathPrefix?: string }, + ): Promise<{ key: string }>; + getUrl(key: string): string | Promise; +} diff --git a/server/src/modules/file/utils/generateUniqueFilename.ts b/server/src/modules/file/utils/generateUniqueFilename.ts new file mode 100644 index 00000000..f48b3ca9 --- /dev/null +++ b/server/src/modules/file/utils/generateUniqueFilename.ts @@ -0,0 +1,11 @@ +import { promisify } from 'util'; +import { randomBytes } from 'crypto'; + +export const generateUniqueFilename = async ( + originalname: string, +): Promise => { + const randomBytesPromise = promisify(randomBytes); + const randomString = (await randomBytesPromise(16)).toString('hex'); + const ext = originalname.split('.').pop(); + return `${randomString}.${ext}`; +}; diff --git a/server/src/modules/file/utils/parseExpiryTimeToSeconds.ts b/server/src/modules/file/utils/parseExpiryTimeToSeconds.ts new file mode 100644 index 00000000..e14d78d2 --- /dev/null +++ b/server/src/modules/file/utils/parseExpiryTimeToSeconds.ts @@ -0,0 +1,13 @@ +export const parseExpiryTimeToSeconds = (expiryTime: string): number => { + const units: { [key: string]: number } = { + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 604800, + }; + + const unit = expiryTime.charAt(expiryTime.length - 1); + const time = parseInt(expiryTime.slice(0, -1)); + return units[unit] * time; +}; diff --git a/server/src/modules/file/utils/replaceFileKey.ts b/server/src/modules/file/utils/replaceFileKey.ts new file mode 100644 index 00000000..56c26e0d --- /dev/null +++ b/server/src/modules/file/utils/replaceFileKey.ts @@ -0,0 +1,12 @@ +export const replaceFileKey = ( + originStr: string, + arr: Array<{ key; value }>, +) => { + let retStr = originStr; + for (const { key, value } of arr) { + if (value) { + retStr = retStr.replace(new RegExp(`{${key}}`), value); + } + } + return retStr; +}; diff --git a/server/src/modules/message/messagePushing.module.ts b/server/src/modules/message/message.module.ts similarity index 96% rename from server/src/modules/message/messagePushing.module.ts rename to server/src/modules/message/message.module.ts index 36bd6922..0d726c0e 100644 --- a/server/src/modules/message/messagePushing.module.ts +++ b/server/src/modules/message/message.module.ts @@ -28,4 +28,4 @@ import { LoggerProvider } from 'src/logger/logger.provider'; ], exports: [MessagePushingTaskService], }) -export class MessagePushingModule {} +export class MessageModule {} diff --git a/server/src/modules/surveyResponse/surveyResponse.module.ts b/server/src/modules/surveyResponse/surveyResponse.module.ts index 0c95105d..357644c6 100644 --- a/server/src/modules/surveyResponse/surveyResponse.module.ts +++ b/server/src/modules/surveyResponse/surveyResponse.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; -import { MessagePushingModule } from '../message/messagePushing.module'; +import { MessageModule } from '../message/message.module'; import { ResponseSchemaService } from './services/responseScheme.service'; import { SurveyResponseService } from './services/surveyResponse.service'; @@ -30,7 +30,7 @@ import { ConfigModule } from '@nestjs/config'; ClientEncrypt, ]), ConfigModule, - MessagePushingModule, + MessageModule, ], controllers: [ ClientEncryptController,