134 lines
5.2 KiB
JavaScript
134 lines
5.2 KiB
JavaScript
/**
|
|
* 腾讯 COS(轻量对象存储)XML API 极简上传客户端 — 零外部依赖
|
|
*
|
|
* 用法:
|
|
* node scripts/cos-upload.mjs --check 连通性检查(HeadBucket)
|
|
* node scripts/cos-upload.mjs <本地文件> <桶内Key> [--cache-control "no-cache"]
|
|
*
|
|
* 密钥来源(按优先级): 命令行环境变量 > desktop/.env.local
|
|
* TENCENT_SECRET_ID / TENCENT_SECRET_KEY
|
|
* 桶与地域(可用环境变量覆盖):
|
|
* COS_BUCKET(默认 huobao-installer-1304922933)/ COS_REGION(默认 ap-guangzhou)
|
|
*
|
|
* 签名: q-sign-algorithm=sha1(腾讯云 COS XML API 鉴权规范),
|
|
* 只签 host 头,其余头(Cache-Control 等)透传不参与签名。
|
|
*/
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import crypto from 'crypto'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
const DESKTOP = path.resolve(__dirname, '..')
|
|
|
|
// 读取 desktop/.env.local(KEY=VALUE 行),不覆盖已有环境变量
|
|
function loadEnvLocal() {
|
|
const envFile = path.join(DESKTOP, '.env.local')
|
|
if (!fs.existsSync(envFile)) return
|
|
for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
|
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$/)
|
|
if (m && !process.env[m[1]]) process.env[m[1]] = m[2]
|
|
}
|
|
}
|
|
loadEnvLocal()
|
|
|
|
const SECRET_ID = process.env.TENCENT_SECRET_ID
|
|
const SECRET_KEY = process.env.TENCENT_SECRET_KEY
|
|
const BUCKET = process.env.COS_BUCKET || 'huobao-installer-1304922933'
|
|
const REGION = process.env.COS_REGION || 'ap-guangzhou'
|
|
const HOST = `${BUCKET}.cos.${REGION}.myqcloud.com`
|
|
|
|
function sha1Hex(input) {
|
|
return crypto.createHash('sha1').update(input).digest('hex')
|
|
}
|
|
function hmacSha1Hex(key, input) {
|
|
return crypto.createHmac('sha1', key).update(input).digest('hex')
|
|
}
|
|
|
|
/** COS q-signature(仅签 host 头、无 url 参数) */
|
|
function authorization(method, uriPathname) {
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const keyTime = `${now - 60};${now + 3600}`
|
|
const signKey = hmacSha1Hex(SECRET_KEY, keyTime)
|
|
const httpString = `${method.toLowerCase()}\n${uriPathname}\n\nhost=${encodeURIComponent(HOST)}\n`
|
|
const stringToSign = `sha1\n${keyTime}\n${sha1Hex(httpString)}\n`
|
|
const signature = hmacSha1Hex(signKey, stringToSign)
|
|
return `q-sign-algorithm=sha1&q-ak=${SECRET_ID}&q-sign-time=${keyTime}&q-key-time=${keyTime}`
|
|
+ `&q-header-list=host&q-url-param-list=&q-signature=${signature}`
|
|
}
|
|
|
|
export async function cosPut(localFile, key, { cacheControl, contentType } = {}) {
|
|
const body = fs.readFileSync(localFile)
|
|
const uriPathname = `/${key.split('/').map(encodeURIComponent).join('/')}`
|
|
const headers = {
|
|
'Host': HOST,
|
|
'Authorization': authorization('PUT', uriPathname),
|
|
'Content-Length': String(body.length),
|
|
'Content-MD5': crypto.createHash('md5').update(body).digest('base64'),
|
|
}
|
|
if (cacheControl) headers['Cache-Control'] = cacheControl
|
|
if (contentType) headers['Content-Type'] = contentType
|
|
|
|
const res = await fetch(`https://${HOST}${uriPathname}`, {
|
|
method: 'PUT',
|
|
headers,
|
|
body,
|
|
signal: AbortSignal.timeout(600_000),
|
|
})
|
|
if (!res.ok) {
|
|
const text = await res.text()
|
|
throw new Error(`COS PUT ${key} 失败 HTTP ${res.status}: ${text.slice(0, 300)}`)
|
|
}
|
|
return `https://${HOST}${uriPathname}`
|
|
}
|
|
|
|
export async function cosHeadBucket() {
|
|
const res = await fetch(`https://${HOST}/`, {
|
|
method: 'HEAD',
|
|
headers: { 'Host': HOST, 'Authorization': authorization('HEAD', '/') },
|
|
signal: AbortSignal.timeout(15_000),
|
|
})
|
|
return res.status
|
|
}
|
|
|
|
export async function cosDelete(key) {
|
|
const uriPathname = `/${key.split('/').map(encodeURIComponent).join('/')}`
|
|
const res = await fetch(`https://${HOST}${uriPathname}`, {
|
|
method: 'DELETE',
|
|
headers: { 'Host': HOST, 'Authorization': authorization('DELETE', uriPathname) },
|
|
signal: AbortSignal.timeout(15_000),
|
|
})
|
|
if (!res.ok && res.status !== 404) throw new Error(`COS DELETE ${key} 失败 HTTP ${res.status}`)
|
|
}
|
|
|
|
// ---- CLI ----
|
|
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
if (isMain) {
|
|
if (!SECRET_ID || !SECRET_KEY) {
|
|
console.error('缺少 TENCENT_SECRET_ID / TENCENT_SECRET_KEY(写入 desktop/.env.local 或环境变量)')
|
|
process.exit(1)
|
|
}
|
|
const args = process.argv.slice(2)
|
|
if (args[0] === '--check') {
|
|
const status = await cosHeadBucket()
|
|
console.log(status === 200 ? `连通正常 ✓ (${HOST})` : `HeadBucket 返回 ${status}(401=密钥错,403=无权限,404=桶名错)`)
|
|
process.exit(status === 200 ? 0 : 1)
|
|
}
|
|
if (args[0] === '--rm') {
|
|
await cosDelete(args[1])
|
|
console.log(`✓ 已删除 ${args[1]}`)
|
|
process.exit(0)
|
|
}
|
|
const [localFile, key] = args
|
|
const ccIdx = args.indexOf('--cache-control')
|
|
const cacheControl = ccIdx >= 0 ? args[ccIdx + 1] : undefined
|
|
if (!localFile || !key || !fs.existsSync(localFile)) {
|
|
console.error('用法: node scripts/cos-upload.mjs <本地文件> <桶内Key> [--cache-control "..."]')
|
|
process.exit(1)
|
|
}
|
|
const start = Date.now()
|
|
const url = await cosPut(localFile, key, { cacheControl })
|
|
const mb = (fs.statSync(localFile).size / 1048576).toFixed(1)
|
|
console.log(`✓ ${path.basename(localFile)} (${mb}MB, ${((Date.now() - start) / 1000).toFixed(0)}s)\n ${url}`)
|
|
}
|