feat: 新增file模块 (#101)

This commit is contained in:
luch 2024-04-25 13:44:36 +08:00 committed by GitHub
parent a23fc28f5f
commit d3f64707a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 685 additions and 36 deletions

View File

@ -26,18 +26,22 @@
"@nestjs/serve-static": "^4.0.0", "@nestjs/serve-static": "^4.0.0",
"@nestjs/swagger": "^7.3.0", "@nestjs/swagger": "^7.3.0",
"@nestjs/typeorm": "^10.0.1", "@nestjs/typeorm": "^10.0.1",
"ali-oss": "^6.20.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dotenv": "^16.3.2", "dotenv": "^16.3.2",
"fs-extra": "^11.2.0",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"minio": "^7.1.3",
"moment": "^2.30.1", "moment": "^2.30.1",
"mongodb": "^5.9.2", "mongodb": "^5.9.2",
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"node-forge": "^1.3.1", "node-forge": "^1.3.1",
"qiniu": "^7.11.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"svg-captcha": "^1.4.0", "svg-captcha": "^1.4.0",
@ -49,6 +53,7 @@
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/multer": "^1.4.11",
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/node-forge": "^1.3.11", "@types/node-forge": "^1.3.11",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",

View File

@ -11,6 +11,8 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { SurveyModule } from './modules/survey/survey.module'; import { SurveyModule } from './modules/survey/survey.module';
import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.module'; import { SurveyResponseModule } from './modules/surveyResponse/surveyResponse.module';
import { AuthModule } from './modules/auth/auth.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'; import { join } from 'path';
@ -39,7 +41,6 @@ import { Logger } from './logger';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({}), ConfigModule.forRoot({}),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
@ -80,9 +81,17 @@ import { Logger } from './logger';
AuthModule, AuthModule,
SurveyModule, SurveyModule,
SurveyResponseModule, SurveyResponseModule,
ServeStaticModule.forRoot({ ServeStaticModule.forRootAsync({
useFactory: async () => {
return [
{
rootPath: join(__dirname, '..', 'public'), rootPath: join(__dirname, '..', 'public'),
},
];
},
}), }),
MessageModule,
FileModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@ -1,5 +1,5 @@
export enum EXCEPTION_CODE { export enum EXCEPTION_CODE {
AUTHTIFICATION_FAILED = 1001, // 没有权限 AUTHENTICATION_FAILED = 1001, // 没有权限
PARAMETER_ERROR = 1002, // 参数有误 PARAMETER_ERROR = 1002, // 参数有误
USER_EXISTS = 2001, // 用户已存在 USER_EXISTS = 2001, // 用户已存在
USER_NOT_EXISTS = 2002, // 用户不存在 USER_NOT_EXISTS = 2002, // 用户不存在
@ -15,4 +15,6 @@ export enum EXCEPTION_CODE {
RESPONSE_OVER_LIMIT = 9003, // 超出限制 RESPONSE_OVER_LIMIT = 9003, // 超出限制
RESPONSE_SCHEMA_REMOVED = 9004, // 问卷已删除 RESPONSE_SCHEMA_REMOVED = 9004, // 问卷已删除
RESPONSE_DATA_DECRYPT_ERROR = 9005, // 问卷已删除 RESPONSE_DATA_DECRYPT_ERROR = 9005, // 问卷已删除
UPLOAD_FILE_ERROR = 5001, // 上传文件错误
} }

View File

@ -1,7 +1,7 @@
import { HttpException } from './httpException'; import { HttpException } from './httpException';
import { EXCEPTION_CODE } from 'src/enums/exceptionCode'; import { EXCEPTION_CODE } from 'src/enums/exceptionCode';
export class AuthtificationException extends HttpException { export class AuthenticationException extends HttpException {
constructor(public readonly message: string) { constructor(public readonly message: string) {
super(message, EXCEPTION_CODE.AUTHTIFICATION_FAILED); super(message, EXCEPTION_CODE.AUTHENTICATION_FAILED);
} }
} }

View File

@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { Authtication } from './authtication'; import { Authtication } from './authtication';
import { UserService } from '../modules/auth/services/user.service'; import { UserService } from '../modules/auth/services/user.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { AuthtificationException } from '../exceptions/authException'; import { AuthenticationException } from '../exceptions/authException';
import { User } from 'src/models/user.entity'; import { User } from 'src/models/user.entity';
import * as jwt from 'jsonwebtoken'; import * as jwt from 'jsonwebtoken';
@ -47,7 +47,7 @@ describe('Authtication', () => {
}; };
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException, AuthenticationException,
); );
}); });
@ -69,7 +69,7 @@ describe('Authtication', () => {
.mockReturnValue('XIAOJU_SURVEY_JWT_SECRET'); .mockReturnValue('XIAOJU_SURVEY_JWT_SECRET');
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException, AuthenticationException,
); );
}); });
@ -94,7 +94,7 @@ describe('Authtication', () => {
jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(null); jest.spyOn(userService, 'getUserByUsername').mockResolvedValue(null);
await expect(guard.canActivate(context as any)).rejects.toThrow( await expect(guard.canActivate(context as any)).rejects.toThrow(
AuthtificationException, AuthenticationException,
); );
}); });

View File

@ -1,14 +1,13 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { UserService } from '../modules/auth/services/user.service'; import { UserService } from '../modules/auth/services/user.service';
import { verify } from 'jsonwebtoken'; import { AuthenticationException } from '../exceptions/authException';
import { ConfigService } from '@nestjs/config'; import { AuthService } from 'src/modules/auth/services/auth.service';
import { AuthtificationException } from '../exceptions/authException';
@Injectable() @Injectable()
export class Authtication implements CanActivate { export class Authtication implements CanActivate {
constructor( constructor(
private readonly userService: UserService, private readonly userService: UserService,
private readonly configService: ConfigService, private readonly authService: AuthService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
@ -16,25 +15,15 @@ export class Authtication implements CanActivate {
const token = request.headers.authorization?.split(' ')[1]; const token = request.headers.authorization?.split(' ')[1];
if (!token) { if (!token) {
throw new AuthtificationException('请登录'); throw new AuthenticationException('请登录');
} }
let decoded;
try { try {
decoded = verify( const user = await this.authService.verifyToken(token);
token, request.user = user;
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
);
} catch (err) {
throw new AuthtificationException('用户凭证错误');
}
const user = await this.userService.getUserByUsername(decoded.username); // 从数据库中查找用户
if (!user) {
throw new AuthtificationException('用户不存在');
}
request.user = user; // 将用户信息存储在请求中
return true; return true;
} catch (error) {
throw new AuthenticationException(error?.message || '用户凭证错误');
}
} }
} }

View File

@ -15,6 +15,6 @@ import { ConfigModule } from '@nestjs/config';
imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule], imports: [TypeOrmModule.forFeature([User, Captcha]), ConfigModule],
controllers: [AuthController], controllers: [AuthController],
providers: [UserService, AuthService, CaptchaService], providers: [UserService, AuthService, CaptchaService],
exports: [UserService], exports: [UserService, AuthService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,9 +1,15 @@
import { Injectable } from '@nestjs/common'; 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() @Injectable()
export class AuthService { export class AuthService {
constructor(
private readonly configService: ConfigService,
private readonly userService: UserService,
) {}
async generateToken( async generateToken(
{ _id, username }: { _id: string; username: string }, { _id, username }: { _id: string; username: string },
{ secret, expiresIn }: { secret: string; expiresIn: string }, { secret, expiresIn }: { secret: string; expiresIn: string },
@ -12,4 +18,21 @@ export class AuthService {
expiresIn, expiresIn,
}); });
} }
async verifyToken(token: string) {
let decoded;
try {
decoded = verify(
token,
this.configService.get<string>('XIAOJU_SURVEY_JWT_SECRET'),
);
} catch (err) {
throw new Error('用户凭证错误');
}
const user = await this.userService.getUserByUsername(decoded.username);
if (!user) {
throw new Error('用户不存在');
}
return user;
}
} }

View File

@ -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,
};

View File

@ -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<string, string>) {
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<string>(channel)) {
throw new HttpException(
`参数有误:${channel}`,
EXCEPTION_CODE.PARAMETER_ERROR,
);
}
const configKey = this.configService.get<string>(channel);
const needAuth = this.configService.get<boolean>(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<string>(
`${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<string>(channel)) {
throw new HttpException(
'参数有误请检查channel、key',
EXCEPTION_CODE.PARAMETER_ERROR,
);
}
const configKey = this.configService.get<string>(channel);
const url = this.fileService.getUrl({ configKey, key });
return {
code: 200,
data: {
key,
url,
},
};
}
}

View File

@ -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 {}

View File

@ -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<T>(channel, key) {
return this.configService.get<T>(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<string>(
`${configKey}.LOCAL_STATIC_RENDER_TYPE`,
);
let physicalRootPath;
if (staticType === 'nginx') {
physicalRootPath = this.configService.get<string>(
`${configKey}.NGINX_STATIC_PATH`,
);
}
if (!physicalRootPath) {
physicalRootPath = 'public';
}
return new LocalHandler({ physicalRootPath });
// qiniu
// return new QiniuHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// });
// ali-oss
// return new AliOssHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// region: this.configService.get<string>(`${configKey}.REGION`),
// });
// minio
// return new MinIOHandler({
// accessKey: this.configService.get<string>(`${configKey}.ACCESS_KEY`),
// secretKey: this.configService.get<string>(`${configKey}.SECRET_KEY`),
// bucket: this.configService.get<string>(`${configKey}.BUCKET`),
// endPoint: this.configService.get<string>(`${configKey}.ENDPOINT`),
// useSSL: this.configService.get<boolean>(`${configKey}.USE_SSL`),
// isPrivateRead: this.configService.get<boolean>(
// `${configKey}.IS_PRIVATE_READ`,
// ),
// expiryTime: this.configService.get<string>(
// `${configKey}.LINK_EXPIRY_TIME`,
// ),
// region: this.configService.get<string>(`${configKey}.REGION`),
// });
}
}

View File

@ -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}`;
}
}
}

View File

@ -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}`;
}
}

View File

@ -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<string> {
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}`;
}
}

View File

@ -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}`;
}
}

View File

@ -0,0 +1,7 @@
export interface FileUploadHandler {
upload(
file: Express.Multer.File,
options?: { pathPrefix?: string },
): Promise<{ key: string }>;
getUrl(key: string): string | Promise<string>;
}

View File

@ -0,0 +1,11 @@
import { promisify } from 'util';
import { randomBytes } from 'crypto';
export const generateUniqueFilename = async (
originalname: string,
): Promise<string> => {
const randomBytesPromise = promisify(randomBytes);
const randomString = (await randomBytesPromise(16)).toString('hex');
const ext = originalname.split('.').pop();
return `${randomString}.${ext}`;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -28,4 +28,4 @@ import { LoggerProvider } from 'src/logger/logger.provider';
], ],
exports: [MessagePushingTaskService], exports: [MessagePushingTaskService],
}) })
export class MessagePushingModule {} export class MessageModule {}

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MessagePushingModule } from '../message/messagePushing.module'; import { MessageModule } from '../message/message.module';
import { ResponseSchemaService } from './services/responseScheme.service'; import { ResponseSchemaService } from './services/responseScheme.service';
import { SurveyResponseService } from './services/surveyResponse.service'; import { SurveyResponseService } from './services/surveyResponse.service';
@ -30,7 +30,7 @@ import { ConfigModule } from '@nestjs/config';
ClientEncrypt, ClientEncrypt,
]), ]),
ConfigModule, ConfigModule,
MessagePushingModule, MessageModule,
], ],
controllers: [ controllers: [
ClientEncryptController, ClientEncryptController,