|
@@ -1,7 +1,13 @@
|
|
|
#!/usr/bin/env node
|
|
#!/usr/bin/env node
|
|
|
/**
|
|
/**
|
|
|
* fmode-storage uploader — 对象存储上传/公开链接/ACL
|
|
* fmode-storage uploader — 对象存储上传/公开链接/ACL
|
|
|
- * 零依赖(Node ≥18)。凭据按 4 级优先级自动解析,永不入库。
|
|
|
|
|
|
|
+ * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
|
|
|
|
|
+ *
|
|
|
|
|
+ * v0.4.0(2026-09-23):新增**第0级 云函数预签名直传**(首选)。
|
|
|
|
|
+ * 容器只需 FMODE_SESSION_TOKEN,平台云函数 fmodeagent-upload-url 持有 AK/SK 并
|
|
|
|
|
+ * 签发 5 分钟预签名 PUT URL;对象键 user/<userid>/<ns>/<YYYYMM>/...,公网
|
|
|
|
|
+ * https://s3.fmode.cn/<key>。解决"每个容器都要配 OBS AK/SK / storageProjectId"
|
|
|
|
|
+ * 的落地缺口(此前 34 台容器无一具备,技能只能打印向导退出)。
|
|
|
*
|
|
*
|
|
|
* v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
|
|
* v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
|
|
|
* 旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
|
|
* 旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
|
|
@@ -23,7 +29,7 @@
|
|
|
*
|
|
*
|
|
|
* 用法:
|
|
* 用法:
|
|
|
* node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
|
|
* node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
|
|
|
- * node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
|
|
|
|
|
|
|
+ * node uploader.mjs put <file> --key <objectKey> [--ns report] [--acl public-read] [--bucket ...]
|
|
|
* node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
|
|
* node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
|
|
|
* node uploader.mjs test
|
|
* node uploader.mjs test
|
|
|
* node uploader.mjs config
|
|
* node uploader.mjs config
|
|
@@ -46,6 +52,14 @@ const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
|
|
|
const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
|
|
const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
|
|
|
const PROBE_TTL_MS = 60 * 60 * 1000;
|
|
const PROBE_TTL_MS = 60 * 60 * 1000;
|
|
|
|
|
|
|
|
|
|
+// ── 第0级(首选):云函数预签名直传 ─────────────────────────────────────────
|
|
|
|
|
+// fmodeagent-upload-url 云函数保管 AK/SK,按调用者身份签发 5 分钟有效的
|
|
|
|
|
+// OBS SigV2 预签名 PUT URL;容器只拿到 URL,密钥永不下发。
|
|
|
|
|
+// 对象键:user/<userid>/<namespace>/<YYYYMM>/<uuid>-<filename>,公网 https://s3.fmode.cn/<key>
|
|
|
|
|
+const UPLOAD_FN_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/functions`;
|
|
|
|
|
+const UPLOAD_FN_ID = process.env.FMODE_UPLOAD_FN_ID || process.env.FMODE_STORAGE_FN_ID || 'AlP56LCKFm';
|
|
|
|
|
+const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
|
|
|
|
|
+
|
|
|
/** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
|
|
/** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
|
|
|
function printWizard() {
|
|
function printWizard() {
|
|
|
console.error(`
|
|
console.error(`
|
|
@@ -264,6 +278,44 @@ export function publicUrl(cfg, key) {
|
|
|
return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
|
|
return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+// 第0级:云函数预签名直传(免 AK/SK,首选)
|
|
|
|
|
+// ============================================================================
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 申请预签名 URL 并直传(云函数持有真实 AK/SK)。
|
|
|
|
|
+ * @returns {{ok:true, key:string, url:string, bytes:number, via:string}|null}
|
|
|
|
|
+ * null = 该通道不可用(无 sessionToken / 云函数未配置 / 网络失败),调用方回落下一级
|
|
|
|
|
+ */
|
|
|
|
|
+export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null) {
|
|
|
|
|
+ const token = resolveSessionToken();
|
|
|
|
|
+ if (!token) return null;
|
|
|
|
|
+ let buf;
|
|
|
|
|
+ try { buf = fs.readFileSync(file); } catch { return null; }
|
|
|
|
|
+ const filename = name || path.basename(file);
|
|
|
|
|
+ let res;
|
|
|
|
|
+ try {
|
|
|
|
|
+ res = await fetch(UPLOAD_FN_URL, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ token, id: UPLOAD_FN_ID,
|
|
|
|
|
+ params: { filename, size: buf.length, namespace, key: objectKey || undefined },
|
|
|
|
|
+ }),
|
|
|
|
|
+ signal: AbortSignal.timeout(20000),
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch { return null; }
|
|
|
|
|
+ if (!res.ok) return null;
|
|
|
|
|
+ const body = await res.json().catch(() => null);
|
|
|
|
|
+ if (!body || body.code !== 200 || !body.uploadUrl) return null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ const put = await fetch(body.uploadUrl, { method: 'PUT', headers: body.headers || {}, body: buf });
|
|
|
|
|
+ if (!put.ok) return null;
|
|
|
|
|
+ } catch { return null; }
|
|
|
|
|
+ return { ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`, bytes: buf.length, via: `level0:cloudfunc(${UPLOAD_FN_ID})` };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ============================================================================
|
|
// ============================================================================
|
|
|
// 凭据链:诚实 4 级
|
|
// 凭据链:诚实 4 级
|
|
|
// ============================================================================
|
|
// ============================================================================
|
|
@@ -481,6 +533,19 @@ async function cmdInit(rest) {
|
|
|
|
|
|
|
|
/** test:上传 1KB 探针文件→删除→报告成功 */
|
|
/** test:上传 1KB 探针文件→删除→报告成功 */
|
|
|
async function cmdTest() {
|
|
async function cmdTest() {
|
|
|
|
|
+ // 第0级:云函数免密钥通道(首选),能通就直接成功
|
|
|
|
|
+ const probeTmp = path.join(os.tmpdir(), `fmode-storage-cfprobe-${process.pid}.txt`);
|
|
|
|
|
+ fs.writeFileSync(probeTmp, 'fmode-storage cloudfunc probe ' + new Date().toISOString() + '\n');
|
|
|
|
|
+ try {
|
|
|
|
|
+ const r = await putViaCloudFunction(probeTmp, null, 'selftest');
|
|
|
|
|
+ if (r) {
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true, via: r.via, url: r.url, bytes: r.bytes, message: '云函数免密钥通道自检成功(本机无需任何 AK/SK)' }, null, 2));
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ try { fs.rmSync(probeTmp, { force: true }); } catch { /* ignore */ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const cfg = await resolveStorageConfig();
|
|
const cfg = await resolveStorageConfig();
|
|
|
if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
|
|
if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
|
|
|
if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
|
|
if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
|
|
@@ -512,6 +577,29 @@ async function main() {
|
|
|
if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
|
|
if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
|
|
|
if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
|
|
if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
|
|
|
|
|
|
|
|
|
|
+ // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
|
|
|
|
|
+ // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
|
|
|
|
|
+ // 而这条路恰恰是唯一不需要任何本机凭据的通道。
|
|
|
|
|
+ if (cmd === 'put') {
|
|
|
|
|
+ const file0 = rest[0];
|
|
|
|
|
+ const key0 = arg('--key');
|
|
|
|
|
+ if (!file0 || !key0) { console.error('用法: put <file> --key <objectKey> [--acl public-read] [--ns report]'); process.exit(2); }
|
|
|
|
|
+ if (!fs.existsSync(file0)) { console.error('文件不存在: ' + file0); process.exit(2); }
|
|
|
|
|
+ const ns0 = arg('--ns') || 'report';
|
|
|
|
|
+ const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0));
|
|
|
|
|
+ if (cf0) {
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes }, null, 2));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 云函数不可用(无 sessionToken / 云函数未配置)→ 落到下面的 obsutil 4 级兜底
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // setacl:云函数通道下对象在 PUT 时已带 x-obs-acl:public-read,无需单独设 ACL
|
|
|
|
|
+ if (cmd === 'setacl' && resolveSessionToken()) {
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true, via: 'level0:cloudfunc', message: '云函数通道:对象在上传时即带上 x-obs-acl:public-read,无需再单独设置 ACL' }, null, 2));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const cfg = await resolveStorageConfig({ experimentalSts });
|
|
const cfg = await resolveStorageConfig({ experimentalSts });
|
|
|
if (!cfg.ak && !cfg.sts) {
|
|
if (!cfg.ak && !cfg.sts) {
|
|
|
if (cmd !== 'help') {
|
|
if (cmd !== 'help') {
|
|
@@ -534,6 +622,13 @@ async function main() {
|
|
|
if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
|
|
if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
|
|
|
const acl = arg('--acl') || 'public-read';
|
|
const acl = arg('--acl') || 'public-read';
|
|
|
const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
|
|
const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
|
|
|
|
|
+ // 第0级(首选):云函数预签名直传 —— 容器不需要任何 AK/SK
|
|
|
|
|
+ const ns = arg('--ns') || 'report';
|
|
|
|
|
+ const cf = await putViaCloudFunction(file, key, ns, path.basename(key || file));
|
|
|
|
|
+ if (cf) {
|
|
|
|
|
+ console.log(JSON.stringify({ ok: true, key: cf.key, url: cf.url, bucket: 'storage-s3-nkkj', via: cf.via, bytes: cf.bytes }, null, 2));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
key = scopedKey(cfg, key); // STS 签发时强制限定 dev/<projectId>/ 前缀,防越权路径
|
|
key = scopedKey(cfg, key); // STS 签发时强制限定 dev/<projectId>/ 前缀,防越权路径
|
|
|
const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
|
|
const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
|
|
|
if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
|
|
if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
|
|
@@ -566,4 +661,4 @@ async function main() {
|
|
|
console.log('用法: init | test | put | setacl | config');
|
|
console.log('用法: init | test | put | setacl | config');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
|
|
|
|
|
|
|
+main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
|