/** * 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 };