campusloop / r2-storage.js
Dridft's picture
Upload 8 files
d9e5dfd verified
/**
* 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
};