import { HttpStatus, Injectable, Logger, HttpException } from '@nestjs/common' import * as OSS from 'ali-oss' import * as crypto from 'crypto' import { Redis } from 'ioredis' import { AliyunOssConfig, AliyunOssEndpoint } from 'life-helper-config' import { RedisService } from 'nestjs-redis' import { v4 as uuidv4 } from 'uuid' import { ClientToken, DumpDirname, GenerateClientTokenConfig, SaveDirname } from './oss.interface' import axios from 'axios' import { COMMON_SERVER_ERROR } from 'src/common/errors.constant' @Injectable() export class OssService { private readonly logger = new Logger(OssService.name) private readonly ossClient: OSS private readonly redis: Redis constructor(private redisService: RedisService) { this.ossClient = new OSS({ bucket: AliyunOssConfig.res.bucket, accessKeyId: AliyunOssConfig.res.accessKeyId, accessKeySecret: AliyunOssConfig.res.accessKeySecret, endpoint: AliyunOssEndpoint, }) this.redis = this.redisService.getClient() } /** * 资源存储的 OSS Bucket 的 URL 地址,目前固定为: * * ``` * https://res.lifehelper.com.cn * ``` */ get origin(): string { return AliyunOssConfig.res.url } /** * 获取资源的完整 URL 地址 * * @param path 路径 */ getUrl(path: string): string { return this.origin.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '') } /** * 生成一个可用于客户端直传 OSS 的调用凭证 * * @param config 配置项 * * @see [配置内容](https://help.aliyun.com/document_detail/31988.html#title-6w1-wj7-q4e) */ generateClientToken(config: GenerateClientTokenConfig): ClientToken { /** 用于存储由客户端直传的文件的 OSS 存储桶 */ const ossBucket = AliyunOssConfig.res /** 目录名称 */ const dirname = config.dirname if (!dirname) { throw new Error('dirname 为空') } /** 有效时间:默认 4 小时 */ const timeout = (config.expiration || 4) * 60 * 60 * 1000 /** 上传最大体积,默认 100M */ const maxSize = (config.maxSize || 100) * 1024 * 1024 /** 随机文件名(去掉短横线的 uuid) */ const filename = uuidv4().replace(/-/gu, '') /** 文件路径 */ const key = dirname + '/' + filename /** 到期时间:当前时间 + 有效时间 */ const expiration = new Date(Date.now() + timeout).toISOString() const policyText = { expiration: expiration, conditions: [ ['eq', '$bucket', ossBucket.bucket], ['eq', '$key', key], ['content-length-range', 0, maxSize], ], } // 将 policyText 转化为 Base64 格式 const policy = Buffer.from(JSON.stringify(policyText)).toString('base64') // 使用 HmacSha1 算法签名 const signature = crypto.createHmac('sha1', ossBucket.accessKeySecret).update(policy, 'utf8').digest('base64') return { key, policy, signature, OSSAccessKeyId: ossBucket.accessKeyId, url: ossBucket.url, } } /** * 上传文件至 OSS * * @param name 文件路径,最前面不要加 `/` * @param buf 待上传的内容 * @param options 配置 * * @see [管理文件元信息](https://help.aliyun.com/document_detail/31859.htm) */ async upload(name: string, buf: Buffer, options?: OSS.PutObjectOptions): Promise<string> { const result = await this.ossClient.put(name, buf, options) if (result.res.status === 200) { return result.name } this.logger.error(`使用 OSS 上传文件失败,name => ${name}, status => ${result.res.status}`) throw new HttpException(COMMON_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) } /** * 储存文件 * * @param dirname 目录名 * @param buf 待上传的内容 * @param options 配置 */ async save(dirname: SaveDirname, buf: Buffer, options?: OSS.PutObjectOptions): Promise<string> { /** 随机文件名(去掉短横线的 uuid) */ const filename = uuidv4().replace(/-/gu, '') /** 文件路径 */ const key = dirname + '/' + filename await this.upload(key, buf, options) return key } /** * 转储资源至 OSS * * @param url 待转储资源的 URL * @param dirname 转储目录 * @param options 配置 */ async dump(url: string, dirname: DumpDirname, options: OSS.PutObjectOptions = {}): Promise<string> { const response = await axios.request({ method: 'GET', url: url, responseType: 'arraybuffer', }) const field: string = Object.keys(response.headers).find((key: string) => key.toLowerCase() === 'content-type') const contentType = response.headers[field] options.headers = options.headers || {} options.headers['Content-Type'] = contentType /** 随机文件名(去掉短横线的 uuid) */ const filename = uuidv4().replace(/-/gu, '') const name = `${dirname}/${filename}` const result = await this.ossClient.put(name, response.data, options) if (result.res.status === 200) { return result.name } this.logger.error(`转储文件失败, url => ${url}, name => ${name}`) throw new HttpException(COMMON_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) } /** * 获取视频封面图 * * @param url 视频 URL 地址 * * @see [文档地址](https://help.aliyun.com/document_detail/64555.html) */ getVideoSnapshot(url: string) { return url + '?x-oss-process=video/snapshot,t_0,f_jpg,w_0,h_0,m_fast' } /** * 通过检验签名验证回调请求由 OSS 发起 * @see https://help.aliyun.com/document_detail/31989.html#title-neu-ft5-rlp * * 说明: * 1. 请求头 `x-oss-pub-key-url` - 获取公钥的 URL 地址 * 2. 请求头 `authorization` - 签名 * * @description * 很早之前写的,改了几版,没验证过 * */ async verifyOssCallbackSignature(options) { const { signature, ossPubKeyUrlBase64, path, search, rawBody } = options const redis = this.redisService.getClient() /** * 第 1 步:获取公钥 * 1. 获取后会放 Redis 存着,理论上猜测应该不常变。 * 2. 意外处理:如果出现异常则将公钥删除重新获取。 */ let pubKey = '' const redisKey = 'system:oss-public-key' const redisResult = await redis.get(redisKey) if (redisResult) { pubKey = redisResult } else { const ossPubKeyUrl = Buffer.from(ossPubKeyUrlBase64, 'base64').toString() // 校验获取公钥的地址的 host 为 gosspublic.alicdn.com const validHost = 'gosspublic.alicdn.com' if (new URL(ossPubKeyUrl).host !== validHost) { throw new Error(`OSS 回调异常:获取公钥的地址 ${ossPubKeyUrl} 非指定地址,可能为假冒请求!`) } const { data: resData } = await axios(ossPubKeyUrl) if (resData) { pubKey = resData await redis.set(redisKey, resData, 'EX', 3600 * 24 * 10) } else { throw new Error('OSS 回调异常:未获取到公钥内容!') } } // 计算签名字符串 const stringToSign = path + search + '\n' + rawBody /** 签名内容 */ const signatureText = Buffer.from(signature, 'base64') // 进行校验 const verifyResult = crypto.createVerify('RSA-MD5').update(stringToSign).verify(pubKey, signatureText) if (verifyResult) { return true } else { throw new Error('OSS 回调异常:签名校验未通过') } } }