/** * 七牛云上传工具 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); class QiniuUploader { constructor() { this.configPath = process.env.QINIU_CONFIG_PATH || path.join(process.cwd(), 'config', 'qiniu-config.json'); this.config = this.loadConfig(); } // 加载配置 loadConfig() { if (!fs.existsSync(this.configPath)) { // 创建默认配置 const defaultConfig = { buckets: { default: { accessKey: process.env.QINIU_ACCESS_KEY || 'YOUR_ACCESS_KEY', secretKey: process.env.QINIU_SECRET_KEY || 'YOUR_SECRET_KEY', bucket: process.env.QINIU_BUCKET || 'your-bucket', region: process.env.QINIU_REGION || 'z0', domain: process.env.QINIU_DOMAIN || 'https://your-cdn.com' } } }; const dir = path.dirname(this.configPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(this.configPath, JSON.stringify(defaultConfig, null, 2)); return defaultConfig; } return JSON.parse(fs.readFileSync(this.configPath, 'utf-8')); } // 列出配置 async listConfig() { const config = this.loadConfig(); const buckets = {}; for (const [name, bucket] of Object.entries(config.buckets)) { buckets[name] = { accessKey: this.maskKey(bucket.accessKey), secretKey: this.maskKey(bucket.secretKey), bucket: bucket.bucket, region: bucket.region, domain: bucket.domain }; } return { buckets }; } // 设置配置值 async setConfigValue(keyPath, value) { const config = this.loadConfig(); const keys = keyPath.split('.'); let current = config; for (let i = 0; i < keys.length - 1; i++) { if (!current[keys[i]]) { current[keys[i]] = {}; } current = current[keys[i]]; } const lastKey = keys[keys.length - 1]; // 清理 URL 中的 Markdown 格式(防止用户从卡片复制时带入) if (typeof value === 'string' && (value.includes('http://') || value.includes('https://'))) { // 移除 Markdown 链接格式:[text](url) → url const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/; const match = value.match(markdownLinkRegex); if (match) { value = match[2]; // 提取 URL 部分 } // 移除末尾的斜杠 if (value.endsWith('/')) { value = value.slice(0, -1); } } // 类型转换 if (value === 'true') value = true; else if (value === 'false') value = false; else if (!isNaN(value) && !value.includes('.')) value = Number(value); current[lastKey] = value; fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2)); this.config = config; } // 上传文件 async upload(localFile, key, bucketName = 'default') { const bucketConfig = this.config.buckets[bucketName]; if (!bucketConfig) { throw new Error(`存储桶 "${bucketName}" 不存在`); } const { accessKey, secretKey, bucket, region, domain } = bucketConfig; // 生成上传凭证 const uploadToken = this.generateUploadToken(accessKey, secretKey, bucket, key); // 获取上传端点 const uploadEndpoint = this.getUploadEndpoint(region); // 读取文件 const fileContent = fs.readFileSync(localFile); const fileName = path.basename(localFile); // 构建 multipart/form-data const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2); const bodyParts = [ `------${boundary}`, 'Content-Disposition: form-data; name="token"', '', uploadToken, `------${boundary}`, 'Content-Disposition: form-data; name="key"', '', key, `------${boundary}`, `Content-Disposition: form-data; name="file"; filename="${fileName}"`, 'Content-Type: application/octet-stream', '', '' ]; const bodyBuffer = Buffer.concat([ Buffer.from(bodyParts.join('\r\n'), 'utf-8'), fileContent, Buffer.from(`\r\n------${boundary}--\r\n`, 'utf-8') ]); // 上传 const result = await this.httpRequest(`${uploadEndpoint}/`, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=----${boundary}`, 'Content-Length': bodyBuffer.length } }, bodyBuffer); if (result.status !== 200) { throw new Error(`上传失败:${JSON.stringify(result.data)}`); } return { key: result.data.key, hash: result.data.hash, url: `${domain}/${key}` }; } // 刷新 CDN async refreshCDN(bucketName, key, logCallback) { const bucketConfig = this.config.buckets[bucketName]; if (!bucketConfig) { if (logCallback) logCallback(`[CDN 刷新] 存储桶 "${bucketName}" 配置不存在`); return; } const { accessKey, secretKey, domain } = bucketConfig; // 检查密钥是否有效 if (!accessKey || !secretKey || accessKey === 'YOUR_ACCESS_KEY' || secretKey === 'YOUR_SECRET_KEY') { if (logCallback) logCallback(`[CDN 刷新] 警告:存储桶 "${bucketName}" 的密钥未配置,跳过 CDN 刷新`); return; } const fileUrl = `${domain}/${key}`; if (logCallback) logCallback(`[CDN 刷新] 请求刷新 URL: ${fileUrl}`); const body = JSON.stringify({ urls: [fileUrl] }); const accessToken = this.generateAccessToken(accessKey, secretKey, 'POST', '/v2/tune/refresh', body); try { const result = await this.httpRequest('https://fusion.qiniuapi.com/v2/tune/refresh', { method: 'POST', headers: { 'Host': 'fusion.qiniuapi.com', 'Content-Type': 'application/json', 'Content-Length': body.length, 'Authorization': accessToken } }, body); if (logCallback) logCallback(`[CDN 刷新] 响应状态:${result.status}, 数据:${JSON.stringify(result.data)}`); if (result.status !== 200) { throw new Error(`CDN 刷新失败:${JSON.stringify(result.data)}`); } if (logCallback) logCallback(`[CDN 刷新] ✅ 刷新成功`); return result.data; } catch (error) { if (logCallback) logCallback(`[CDN 刷新] ❌ 错误:${error.message}`); throw error; } } // 生成上传凭证 generateUploadToken(accessKey, secretKey, bucket, key) { const deadline = Math.floor(Date.now() / 1000) + 3600; const scope = key ? `${bucket}:${key}` : bucket; const putPolicy = { scope: scope, deadline: deadline }; const encodedPolicy = this.urlSafeBase64(JSON.stringify(putPolicy)); const encodedSignature = this.urlSafeBase64(this.hmacSha1(encodedPolicy, secretKey)); return `${accessKey}:${encodedSignature}:${encodedPolicy}`; } // 生成 CDN 刷新令牌 generateAccessToken(accessKey, secretKey, method, path, body) { const signData = `${method} ${path}\nHost: fusion.qiniuapi.com\nContent-Type: application/json\n\n${body}`; const signature = this.hmacSha1(signData, secretKey); const encodedSign = this.urlSafeBase64(signature); return `Qiniu ${accessKey}:${encodedSign}`; } // 获取上传端点 getUploadEndpoint(region) { const endpoints = { 'z0': 'https://up.qiniup.com', 'z1': 'https://up-z1.qiniup.com', 'z2': 'https://up-z2.qiniup.com', 'na0': 'https://up-na0.qiniup.com', 'as0': 'https://up-as0.qiniup.com' }; return endpoints[region] || endpoints['z0']; } // 工具函数 hmacSha1(data, secret) { return crypto.createHmac('sha1', secret).update(data).digest(); } urlSafeBase64(data) { return Buffer.from(data).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); } maskKey(key) { if (!key || key.length < 8) return '***'; return key.substring(0, 4) + '...' + key.substring(key.length - 4); } httpRequest(url, options, body = null) { return new Promise((resolve, reject) => { const protocol = url.startsWith('https') ? https : http; const req = protocol.request(url, options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { const json = JSON.parse(data); resolve({ status: res.statusCode, data: json }); } catch (e) { resolve({ status: res.statusCode, data: data }); } }); }); req.on('error', reject); if (body) req.write(body); req.end(); }); } } module.exports = { QiniuUploader };