- setConfigValue: 添加 URL 清理逻辑,移除 Markdown 链接格式和末尾斜杠 - handleConfigCommandV2: /config add 命令自动清理 domain 参数 - 防止用户从飞书卡片复制 URL 时带入 Markdown 格式 修复场景: - 用户从卡片复制域名 [https://example.com](https://example.com/) - 使用 /config set 命令设置 domain - 自动清理为纯 URL: https://example.com
294 lines
8.6 KiB
JavaScript
294 lines
8.6 KiB
JavaScript
/**
|
|
* 七牛云上传工具
|
|
*/
|
|
|
|
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 };
|