Spaces:
Runtime error
Runtime error
| /** | |
| * Cloudflare R2 图片存储模块 | |
| * | |
| * 配置说明: | |
| * 在 .env 文件中添加以下环境变量: | |
| * R2_ACCOUNT_ID=你的Cloudflare账户ID | |
| * R2_ACCESS_KEY_ID=你的R2访问密钥ID | |
| * R2_SECRET_ACCESS_KEY=你的R2秘密访问密钥 | |
| * R2_BUCKET_NAME=campusloop-images | |
| * R2_PUBLIC_URL=https://你的公开访问域名(可选) | |
| */ | |
| const crypto = require('crypto'); | |
| const https = require('https'); | |
| const http = require('http'); | |
| class R2Storage { | |
| constructor() { | |
| this.accountId = process.env.R2_ACCOUNT_ID; | |
| this.accessKeyId = process.env.R2_ACCESS_KEY_ID; | |
| this.secretAccessKey = process.env.R2_SECRET_ACCESS_KEY; | |
| this.bucketName = process.env.R2_BUCKET_NAME || 'campusloop-images'; | |
| this.publicUrl = process.env.R2_PUBLIC_URL; | |
| // R2 端点 | |
| this.endpoint = `${this.accountId}.r2.cloudflarestorage.com`; | |
| this.region = 'auto'; | |
| this.service = 's3'; | |
| } | |
| /** | |
| * 检查 R2 是否已配置 | |
| */ | |
| isConfigured() { | |
| return !!(this.accountId && this.accessKeyId && this.secretAccessKey); | |
| } | |
| /** | |
| * 生成 AWS Signature V4 签名 | |
| */ | |
| sign(key, msg) { | |
| return crypto.createHmac('sha256', key).update(msg, 'utf8').digest(); | |
| } | |
| getSignatureKey(dateStamp) { | |
| const kDate = this.sign('AWS4' + this.secretAccessKey, dateStamp); | |
| const kRegion = this.sign(kDate, this.region); | |
| const kService = this.sign(kRegion, this.service); | |
| const kSigning = this.sign(kService, 'aws4_request'); | |
| return kSigning; | |
| } | |
| /** | |
| * 上传文件到 R2 | |
| * @param {Buffer} fileBuffer - 文件内容 | |
| * @param {string} fileName - 文件名 | |
| * @param {string} contentType - MIME 类型 | |
| * @param {Object} options - 可选配置 | |
| * @param {string} options.customKey - 自定义存储路径(用于覆盖上传) | |
| * @returns {Promise<{success: boolean, url: string, key: string}>} | |
| */ | |
| async uploadFile(fileBuffer, fileName, contentType, options = {}) { | |
| if (!this.isConfigured()) { | |
| throw new Error('R2 存储未配置,请设置环境变量'); | |
| } | |
| let key; | |
| if (options.customKey) { | |
| // 使用自定义路径(用于头像等需要覆盖的场景) | |
| key = options.customKey; | |
| } else { | |
| // 生成唯一的文件名 | |
| const timestamp = Date.now(); | |
| const randomStr = crypto.randomBytes(8).toString('hex'); | |
| const ext = fileName.split('.').pop() || 'jpg'; | |
| key = `uploads/${timestamp}-${randomStr}.${ext}`; | |
| } | |
| const now = new Date(); | |
| const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); | |
| const dateStamp = amzDate.slice(0, 8); | |
| const method = 'PUT'; | |
| const canonicalUri = `/${this.bucketName}/${key}`; | |
| const host = this.endpoint; | |
| // 计算内容哈希 | |
| const payloadHash = crypto.createHash('sha256').update(fileBuffer).digest('hex'); | |
| // 构建规范请求 | |
| const canonicalHeaders = [ | |
| `content-type:${contentType}`, | |
| `host:${host}`, | |
| `x-amz-content-sha256:${payloadHash}`, | |
| `x-amz-date:${amzDate}` | |
| ].join('\n') + '\n'; | |
| const signedHeaders = 'content-type;host;x-amz-content-sha256;x-amz-date'; | |
| const canonicalRequest = [ | |
| method, | |
| canonicalUri, | |
| '', // 查询字符串为空 | |
| canonicalHeaders, | |
| signedHeaders, | |
| payloadHash | |
| ].join('\n'); | |
| // 构建待签名字符串 | |
| const algorithm = 'AWS4-HMAC-SHA256'; | |
| const credentialScope = `${dateStamp}/${this.region}/${this.service}/aws4_request`; | |
| const canonicalRequestHash = crypto.createHash('sha256').update(canonicalRequest).digest('hex'); | |
| const stringToSign = [ | |
| algorithm, | |
| amzDate, | |
| credentialScope, | |
| canonicalRequestHash | |
| ].join('\n'); | |
| // 计算签名 | |
| const signingKey = this.getSignatureKey(dateStamp); | |
| const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex'); | |
| // 构建授权头 | |
| const authorization = `${algorithm} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; | |
| // 发送请求 | |
| return new Promise((resolve, reject) => { | |
| const options = { | |
| hostname: host, | |
| port: 443, | |
| path: canonicalUri, | |
| method: method, | |
| headers: { | |
| 'Content-Type': contentType, | |
| 'Content-Length': fileBuffer.length, | |
| 'Host': host, | |
| 'X-Amz-Content-Sha256': payloadHash, | |
| 'X-Amz-Date': amzDate, | |
| 'Authorization': authorization | |
| } | |
| }; | |
| const req = https.request(options, (res) => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => { | |
| if (res.statusCode === 200 || res.statusCode === 201) { | |
| // 构建公开访问 URL | |
| let publicUrl; | |
| if (this.publicUrl) { | |
| publicUrl = `${this.publicUrl}/${key}`; | |
| } else { | |
| // 使用 R2 公开访问 URL(需要在 Cloudflare 配置) | |
| publicUrl = `https://pub-${this.accountId}.r2.dev/${this.bucketName}/${key}`; | |
| } | |
| resolve({ | |
| success: true, | |
| url: publicUrl, | |
| key: key, | |
| size: fileBuffer.length | |
| }); | |
| } else { | |
| reject(new Error(`R2 上传失败: ${res.statusCode} - ${data}`)); | |
| } | |
| }); | |
| }); | |
| req.on('error', (error) => { | |
| reject(new Error(`R2 请求错误: ${error.message}`)); | |
| }); | |
| req.write(fileBuffer); | |
| req.end(); | |
| }); | |
| } | |
| /** | |
| * 删除文件 | |
| * @param {string} key - 文件键名 | |
| */ | |
| async deleteFile(key) { | |
| if (!this.isConfigured()) { | |
| throw new Error('R2 存储未配置'); | |
| } | |
| const now = new Date(); | |
| const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); | |
| const dateStamp = amzDate.slice(0, 8); | |
| const method = 'DELETE'; | |
| const canonicalUri = `/${this.bucketName}/${key}`; | |
| const host = this.endpoint; | |
| const payloadHash = crypto.createHash('sha256').update('').digest('hex'); | |
| const canonicalHeaders = [ | |
| `host:${host}`, | |
| `x-amz-content-sha256:${payloadHash}`, | |
| `x-amz-date:${amzDate}` | |
| ].join('\n') + '\n'; | |
| const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'; | |
| const canonicalRequest = [ | |
| method, | |
| canonicalUri, | |
| '', | |
| canonicalHeaders, | |
| signedHeaders, | |
| payloadHash | |
| ].join('\n'); | |
| const algorithm = 'AWS4-HMAC-SHA256'; | |
| const credentialScope = `${dateStamp}/${this.region}/${this.service}/aws4_request`; | |
| const canonicalRequestHash = crypto.createHash('sha256').update(canonicalRequest).digest('hex'); | |
| const stringToSign = [ | |
| algorithm, | |
| amzDate, | |
| credentialScope, | |
| canonicalRequestHash | |
| ].join('\n'); | |
| const signingKey = this.getSignatureKey(dateStamp); | |
| const signature = crypto.createHmac('sha256', signingKey).update(stringToSign).digest('hex'); | |
| const authorization = `${algorithm} Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; | |
| return new Promise((resolve, reject) => { | |
| const options = { | |
| hostname: host, | |
| port: 443, | |
| path: canonicalUri, | |
| method: method, | |
| headers: { | |
| 'Host': host, | |
| 'X-Amz-Content-Sha256': payloadHash, | |
| 'X-Amz-Date': amzDate, | |
| 'Authorization': authorization | |
| } | |
| }; | |
| const req = https.request(options, (res) => { | |
| let data = ''; | |
| res.on('data', chunk => data += chunk); | |
| res.on('end', () => { | |
| if (res.statusCode === 204 || res.statusCode === 200) { | |
| resolve({ success: true }); | |
| } else { | |
| reject(new Error(`R2 删除失败: ${res.statusCode}`)); | |
| } | |
| }); | |
| }); | |
| req.on('error', reject); | |
| req.end(); | |
| }); | |
| } | |
| } | |
| // 导出单例 | |
| const r2Storage = new R2Storage(); | |
| module.exports = { | |
| R2Storage, | |
| r2Storage | |
| }; | |