Initial commit: 七牛云上传飞书机器人
功能: - 飞书交互卡片支持 - 七牛云文件上传 - 自动 CDN 刷新 - 多存储桶配置 - 跨平台部署(Linux/macOS/Windows) - Docker 支持
This commit is contained in:
253
src/qiniu-uploader.js
Normal file
253
src/qiniu-uploader.js
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 七牛云上传工具
|
||||
*/
|
||||
|
||||
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];
|
||||
|
||||
// 类型转换
|
||||
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) {
|
||||
const bucketConfig = this.config.buckets[bucketName];
|
||||
if (!bucketConfig) return;
|
||||
|
||||
const { accessKey, secretKey, domain } = bucketConfig;
|
||||
const fileUrl = `${domain}/${key}`;
|
||||
|
||||
const body = JSON.stringify({ urls: [fileUrl] });
|
||||
const accessToken = this.generateAccessToken(accessKey, secretKey, 'POST', '/v2/tune/refresh', body);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 生成上传凭证
|
||||
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 };
|
||||
Reference in New Issue
Block a user