// 零依赖 OSS 客户端(通用安装、无服务端项目时的回退实现): // OSS ListObjectsV2 + V1 签名(Header Authorization: OSS AK:SIG),与 server/services/oss-client.js 行为对齐 // 凭据只从进程环境读取(scripts/oss.mjs 已先 loadEnv 注入),本模块不含任何密钥 import crypto from 'crypto'; export function ossConfig() { return { bucket: process.env.OSS_BUCKET || '', region: process.env.OSS_REGION || '', accessKeyId: process.env.OSS_ACCESS_KEY_ID || '', accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '', }; } export function ossEnabled() { const c = ossConfig(); return Boolean(c.bucket && c.region && c.accessKeyId && c.accessKeySecret); } /** 公开读直链域名: 默认桶默认域名(oss-.aliyuncs.com),配 OSS_PUBLIC_DOMAIN 可切换(同服务端规则) */ export function publicDomain() { const c = ossConfig(); return (process.env.OSS_PUBLIC_DOMAIN || `${c.bucket}.oss-${c.region}.aliyuncs.com`).replace(/^https?:\/\//, ''); } function xmlDecode(s) { return String(s) .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'") .replace(/&#x([0-9a-f]+);/gi, (m, n) => { try { return String.fromCharCode(parseInt(n, 16)); } catch { return m; } }) .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(Number(n)); } catch { return m; } }); } function xmlTag(text, name) { const m = new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`).exec(text); return m ? xmlDecode(m[1]) : null; } // 与 ali-oss 一致的 V1 签名: // - canonicalized resource = /bucket/ + (?continuation-token=<原值> 仅当存在) —— continue-token 是子资源 // - list-type/max-keys/prefix 为普通 query,不参与签名,只在请求 URL 中 percent 编码 // - 日期走 x-oss-date 头(GMT) export async function _listPage(params, token) { const c = ossConfig(); const date = new Date().toUTCString(); const urlQ = Object.entries(params) .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) .concat(token ? [`continuation-token=${encodeURIComponent(token)}`] : []) .join('&'); const canonicalResource = `/${c.bucket}/${token ? `?continuation-token=${token}` : ''}`; const stringToSign = ['GET', '', '', date, `x-oss-date:${date}`, canonicalResource].join('\n'); const sig = crypto.createHmac('sha1', c.accessKeySecret).update(stringToSign).digest('base64'); const url = `https://${c.bucket}.oss-${c.region}.aliyuncs.com/?${urlQ}`; const res = await fetch(url, { headers: { 'x-oss-date': date, Authorization: `OSS ${c.accessKeyId}:${sig}` }, }); if (!res.ok) throw new Error(`OSS 列举失败 HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`); return await res.text(); } /** 列举桶内对象(prefix 为空=全桶),与 server/services/oss-client.js listObjects 返回结构一致 */ export async function listObjects(prefix = '') { const objects = []; let token = null; for (;;) { const params = prefix ? { 'list-type': '2', 'max-keys': '1000', prefix } : { 'list-type': '2', 'max-keys': '1000' }; const text = await _listPage(params, token); const contents = new RegExp('([\\s\\S]*?)<\\/Contents>', 'g'); let m; while ((m = contents.exec(text))) { const size = Number(xmlTag(m[1], 'Size')) || 0; const last = xmlTag(m[1], 'LastModified'); objects.push({ name: xmlTag(m[1], 'Key'), size, lastModified: last ? new Date(last).toISOString() : null, }); } if (xmlTag(text, 'IsTruncated') !== 'true') break; token = xmlTag(text, 'NextContinuationToken'); if (!token) break; } return objects; }