|
|
@@ -0,0 +1,84 @@
|
|
|
+// 零依赖 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-<region>.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('<Contents>([\\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;
|
|
|
+}
|