#!/usr/bin/env node /** * 七牛云文件上传脚本 v2 * * 功能: * 1. 上传文件到七牛云对象存储(支持指定路径) * 2. 使用原文件名或自定义文件名 * 3. 刷新 CDN 缓存 * 4. 支持配置管理 * * 使用方式: * * # 上传文件 * node upload-to-qiniu.js upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>] * * # 配置管理 * node upload-to-qiniu.js config list # 查看配置 * node upload-to-qiniu.js config set # 修改单个配置 * node upload-to-qiniu.js config set-bucket # 添加/修改存储桶 * node upload-to-qiniu.js config reset # 重置配置 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const https = require('https'); const http = require('http'); // ============ 配置管理 ============ const DEFAULT_CONFIG_PATH = path.join(process.env.HOME || process.env.USERPROFILE, '.openclaw/credentials/qiniu-config.json'); function loadConfig(configPath = DEFAULT_CONFIG_PATH) { if (!fs.existsSync(configPath)) { throw new Error(`配置文件不存在:${configPath}\n请先创建配置文件或运行:node upload-to-qiniu.js config init`); } return JSON.parse(fs.readFileSync(configPath, 'utf-8')); } function saveConfig(config, configPath = DEFAULT_CONFIG_PATH) { const dir = path.dirname(configPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); } function initConfig() { const defaultConfig = { buckets: { default: { accessKey: "YOUR_ACCESS_KEY_HERE", secretKey: "YOUR_SECRET_KEY_HERE", bucket: "your-bucket-name", region: "z0", domain: "https://your-cdn-domain.com" } }, _comment: { region: "区域代码:z0=华东,z1=华北,z2=华南,na0=北美,as0=东南亚", setup: "1. 获取七牛 AccessKey/SecretKey: https://portal.qiniu.com/user/key", setup2: "2. 创建存储桶并配置 CDN 域名", setup3: "3. 使用 'config set' 命令修改配置" } }; saveConfig(defaultConfig); console.log('✅ 配置文件已初始化:', DEFAULT_CONFIG_PATH); console.log('使用 node upload-to-qiniu.js config set 修改配置'); } function listConfig() { const config = loadConfig(); console.log('📋 当前配置:\n'); console.log('存储桶配置:'); for (const [name, bucket] of Object.entries(config.buckets)) { console.log(`\n 🪣 [${name}]`); console.log(` accessKey: ${maskKey(bucket.accessKey)}`); console.log(` secretKey: ${maskKey(bucket.secretKey)}`); console.log(` bucket: ${bucket.bucket}`); console.log(` region: ${bucket.region}`); console.log(` domain: ${bucket.domain}`); } console.log('\n💡 使用 config set 修改配置'); console.log(' 例如:config set default.accessKey YOUR_NEW_KEY'); } function maskKey(key) { if (!key || key.length < 8) return '***'; return key.substring(0, 4) + '...' + key.substring(key.length - 4); } function setConfigValue(keyPath, value) { const config = 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]; // 类型转换 if (value === 'true') value = true; else if (value === 'false') value = false; else if (!isNaN(value) && !value.includes('.')) value = Number(value); current[lastKey] = value; saveConfig(config); console.log(`✅ 已设置 ${keyPath} = ${value}`); } function setBucket(name, bucketConfig) { const config = loadConfig(); let newConfig; try { newConfig = JSON.parse(bucketConfig); } catch (e) { throw new Error('无效的 JSON 配置,格式:{"accessKey":"...","secretKey":"...","bucket":"...","region":"z0","domain":"..."}'); } config.buckets[name] = newConfig; saveConfig(config); console.log(`✅ 已配置存储桶 [${name}]`); } function resetConfig() { if (fs.existsSync(DEFAULT_CONFIG_PATH)) { fs.unlinkSync(DEFAULT_CONFIG_PATH); } initConfig(); console.log('✅ 配置已重置'); } // ============ 七牛云鉴权 ============ function hmacSha1(data, secret) { return crypto.createHmac('sha1', secret).update(data).digest(); } function urlSafeBase64(data) { return Buffer.from(data).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_'); } function generateUploadToken(accessKey, secretKey, bucket, key = null, expires = 3600) { const deadline = Math.floor(Date.now() / 1000) + expires; // 关键修复:scope 必须包含 key 才能覆盖上传 // 根据七牛文档: // - scope = "bucket" → 只能新增,同名文件会失败 // - scope = "bucket:key" → 允许覆盖同名文件 let scope = bucket; if (key) { scope = `${bucket}:${key}`; // ✅ 添加 key,允许覆盖 } const putPolicy = { scope: scope, deadline: deadline, // insertOnly: 非 0 值才禁止覆盖,默认或 0 都允许覆盖 returnBody: JSON.stringify({ success: true, key: '$(key)', hash: '$(etag)', fsize: '$(fsize)', bucket: '$(bucket)', url: `$(domain)/$(key)` }) }; const encodedPolicy = urlSafeBase64(JSON.stringify(putPolicy)); const encodedSignature = urlSafeBase64(hmacSha1(encodedPolicy, secretKey)); return `${accessKey}:${encodedSignature}:${encodedPolicy}`; } // ============ HTTP 请求工具 ============ function 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(); }); } // ============ 文件上传 ============ async function uploadFile(config, bucketName, localFile, key) { const bucketConfig = config.buckets[bucketName]; if (!bucketConfig) { throw new Error(`存储桶配置 "${bucketName}" 不存在,可用:${Object.keys(config.buckets).join(', ')}`); } const { accessKey, secretKey, bucket, region, domain } = bucketConfig; console.log(`📤 准备上传:${localFile} -> ${bucket}/${key}`); // 1. 生成上传凭证 const uploadToken = generateUploadToken(accessKey, secretKey, bucket, key); // 2. 获取区域上传端点 const regionEndpoint = getUploadEndpoint(region); // 3. 构建 multipart/form-data 请求 const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2); const fileContent = fs.readFileSync(localFile); const fileName = path.basename(localFile); 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') ]); // 使用七牛云标准表单上传 API // 文档:https://developer.qiniu.com/kodo/1312/upload const uploadUrl = `${regionEndpoint}/`; // 根路径,token 在 form-data 中 console.log(`📍 上传端点:${uploadUrl}`); const uploadOptions = { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=----${boundary}`, 'Content-Length': bodyBuffer.length } }; const result = await httpRequest(uploadUrl, uploadOptions, bodyBuffer); if (result.status !== 200) { throw new Error(`上传失败:${JSON.stringify(result.data)}`); } console.log('✅ 上传成功'); return { key: result.data.key, hash: result.data.hash, url: `${domain}/${key}` }; } function 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']; } // ============ CDN 刷新 ============ /** * 生成七牛云 access_token(用于 Fusion CDN API) * 文档:https://developer.qiniu.com/kodo/manual/access-token */ function generateAccessToken(accessKey, secretKey, method, path, body, contentType = 'application/json') { const host = 'fusion.qiniuapi.com'; // 1. 生成待签名的原始字符串 // 格式:Method Path\nHost: Host\nContent-Type: ContentType\n\nBody const signData = `${method} ${path}\nHost: ${host}\nContent-Type: ${contentType}\n\n${body}`; // 2. 使用 HMAC-SHA1 签名 const signature = hmacSha1(signData, secretKey); // 3. URL 安全的 Base64 编码 const encodedSign = urlSafeBase64(signature); // 4. 生成 access_token return `Qiniu ${accessKey}:${encodedSign}`; } async function refreshCDN(config, bucketName, key) { const bucketConfig = config.buckets[bucketName]; if (!bucketConfig) { throw new Error(`存储桶配置 "${bucketName}" 不存在`); } const { accessKey, secretKey, domain } = bucketConfig; const fileUrl = `${domain}/${key}`; console.log(`🔄 刷新 CDN: ${fileUrl}`); const refreshUrl = 'https://fusion.qiniuapi.com/v2/tune/refresh'; const body = JSON.stringify({ urls: [fileUrl] }); const method = 'POST'; const path = '/v2/tune/refresh'; const contentType = 'application/json'; // 生成正确的 access_token const accessToken = generateAccessToken(accessKey, secretKey, method, path, body, contentType); const options = { method: method, headers: { 'Host': 'fusion.qiniuapi.com', 'Content-Type': contentType, 'Content-Length': body.length, 'Authorization': accessToken } }; const result = await httpRequest(refreshUrl, options, body); if (result.status !== 200) { throw new Error(`CDN 刷新失败:${JSON.stringify(result.data)}`); } console.log('✅ CDN 刷新请求已提交'); return result.data; } // ============ 命令行解析 ============ function parseArgs(args) { const params = {}; const positional = []; for (let i = 0; i < args.length; i++) { if (args[i].startsWith('--')) { const key = args[i].slice(2); params[key] = args[i + 1]; i++; } else { positional.push(args[i]); } } return { params, positional }; } // ============ 主函数 ============ async function main() { const args = process.argv.slice(2); if (args.length === 0) { printUsage(); process.exit(0); } const command = args[0]; try { // 配置管理命令 if (command === 'config') { const subCommand = args[1]; switch (subCommand) { case 'init': initConfig(); break; case 'list': listConfig(); break; case 'set': if (!args[2] || !args[3]) { console.error('用法:config set '); console.error('示例:config set default.accessKey YOUR_KEY'); process.exit(1); } setConfigValue(args[2], args[3]); break; case 'set-bucket': if (!args[2] || !args[3]) { console.error('用法:config set-bucket '); console.error('示例:config set-bucket prod \'{"accessKey":"...","secretKey":"...","bucket":"prod","region":"z0","domain":"https://..."}\''); process.exit(1); } setBucket(args[2], args[3]); break; case 'reset': resetConfig(); break; default: console.error(`未知命令:config ${subCommand}`); printConfigUsage(); process.exit(1); } return; } // 上传命令 if (command === 'upload') { const { params, positional } = parseArgs(args.slice(1)); if (!params.file) { console.error('❌ 缺少必需参数 --file'); console.error('用法:upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>]'); console.error('示例:upload --file ./report.pdf --key /config/test/report.pdf --bucket default'); process.exit(1); } const bucketName = params.bucket || 'default'; // 检查文件是否存在 if (!fs.existsSync(params.file)) { throw new Error(`文件不存在:${params.file}`); } // 确定目标文件名 let key = params.key; if (!key) { // 使用原文件名 key = path.basename(params.file); } else if (key.startsWith('/')) { // 如果指定了路径,保留路径 key = key.substring(1); } // 加载配置 const config = loadConfig(); // 获取实际存储桶名称(不是配置别名) const bucketConfig = config.buckets[bucketName]; const actualBucketName = bucketConfig ? bucketConfig.bucket : bucketName; // 上传文件 const uploadResult = await uploadFile(config, bucketName, params.file, key); // 刷新 CDN const refreshResult = await refreshCDN(config, bucketName, key); console.log('\n🎉 完成!'); console.log(`📦 文件:${key}`); console.log(`🔗 URL: ${uploadResult.url}`); console.log(`☁️ 存储桶:${actualBucketName}`); console.log(`📊 刷新请求 ID: ${refreshResult.requestId || 'N/A'}`); return; } // 未知命令 console.error(`未知命令:${command}`); printUsage(); process.exit(1); } catch (error) { console.error('❌ 错误:', error.message); process.exit(1); } } function printUsage() { console.log(` 🍙 七牛云上传工具 v2 用法: node upload-to-qiniu.js [options] 命令: upload 上传文件到七牛云 config 配置管理 上传文件: node upload-to-qiniu.js upload --file <文件路径> [--key <目标路径>] [--bucket <存储桶名>] 选项: --file 本地文件路径(必需) --key 目标路径(可选,默认使用原文件名) --bucket 存储桶名称(可选,默认 default) 示例: # 使用原文件名上传 node upload-to-qiniu.js upload --file ./report.pdf # 指定目标路径 node upload-to-qiniu.js upload --file ./report.pdf --key /config/test/report.pdf # 指定存储桶 node upload-to-qiniu.js upload --file ./report.pdf --key /docs/report.pdf --bucket production 配置管理: node upload-to-qiniu.js config 子命令: init 初始化配置文件 list 查看当前配置 set 修改单个配置项 set-bucket 添加/修改存储桶配置 reset 重置配置 示例: node upload-to-qiniu.js config list node upload-to-qiniu.js config set default.accessKey YOUR_KEY node upload-to-qiniu.js config set default.domain https://cdn.example.com node upload-to-qiniu.js config set-bucket prod '{"accessKey":"...","secretKey":"...","bucket":"prod","region":"z0","domain":"https://..."}' `); } function printConfigUsage() { console.log(` 配置管理命令: config init 初始化配置文件 config list 查看当前配置 config set 修改单个配置 config set-bucket 添加存储桶 config reset 重置配置 示例: config set default.accessKey YOUR_KEY config set default.domain https://cdn.example.com `); } // 运行 if (require.main === module) { main(); } module.exports = { uploadFile, refreshCDN, loadConfig, saveConfig, setConfigValue, setBucket };