uploader.mjs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env node
  2. /**
  3. * fmode-storage uploader — 对象存储上传/公开链接/ACL
  4. * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
  5. *
  6. * 第0级(自举):sessionToken → POST /api/storage/credentials 换 STS 临时凭证,
  7. * 内存持有直传 OBS(不落盘、不进日志)。后续级为回落链(自建 OBS / 已有
  8. * obsutil config 的用户不受影响)。
  9. *
  10. * 用法:
  11. * node uploader.mjs put <file> --key <objectKey> [--acl public-read] [--endpoint ...] [--bucket ...]
  12. * node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
  13. * node uploader.mjs config
  14. */
  15. import fs from 'fs';
  16. import os from 'os';
  17. import path from 'path';
  18. import { execSync, spawnSync } from 'child_process';
  19. const HOME = os.homedir();
  20. // fmode 网关基址(签发 STS / 下载 URL)
  21. const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
  22. const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
  23. /**
  24. * 第0级:解析 sessionToken。
  25. * 来源(优先级):FMODE_SESSION_TOKEN 环境变量
  26. * → ~/.fmode/config.json 的 sessionToken / user.sessionToken
  27. * → ~/.fmode/config/user.json 的 sessionToken
  28. * 找到即返回字符串,找不到返回 null(不报错——交给上层决定是否回落)。
  29. */
  30. export function resolveSessionToken() {
  31. if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
  32. const candidates = [
  33. path.join(HOME, '.fmode', 'config.json'),
  34. path.join(process.cwd(), '.fmode', 'config.json'),
  35. path.join(HOME, '.fmode', 'config', 'user.json'),
  36. ];
  37. for (const p of candidates) {
  38. try {
  39. if (!fs.existsSync(p)) continue;
  40. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  41. const t = j.sessionToken || (j.user && j.user.sessionToken) || null;
  42. if (t && String(t).trim()) return String(t).trim();
  43. } catch { /* try next source */ }
  44. }
  45. return null;
  46. }
  47. /**
  48. * 第0级:sessionToken → STS 临时凭证(AK/SK/SecurityToken,作用域限定用户 prefix)。
  49. *
  50. * 权威链路(fmode-studio/docs/obs-cdn/02-架构与路径划分.md §5.2):
  51. * sessionToken → POST /api/storage/credentials
  52. * → { accessKeyId, secretAccessKey, sessionToken(即 SecurityToken),
  53. * endpoint, bucket, prefix, expiration }
  54. * → 客户端直传 OBS
  55. *
  56. * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  57. *
  58. * @returns {Promise<{ ak: string, sk: string, securityToken: string,
  59. * endpoint: string, bucket: string, prefix: string } | null>}
  60. * 签发失败返回 null(调用方回落第1-4级)。
  61. */
  62. export async function fetchStsCredentials(sessionToken) {
  63. if (!sessionToken) return null;
  64. try {
  65. const res = await fetch(STORAGE_CREDENTIALS_URL, {
  66. method: 'POST',
  67. headers: {
  68. 'Content-Type': 'application/json',
  69. 'Authorization': `Bearer ${sessionToken}`,
  70. },
  71. body: JSON.stringify({ op: ['put', 'delete'] }),
  72. signal: AbortSignal.timeout(15000),
  73. });
  74. if (!res.ok) return null;
  75. const body = await res.json().catch(() => null);
  76. const cred = body && (body.credentials || body.data);
  77. if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
  78. return {
  79. ak: cred.accessKeyId,
  80. sk: cred.secretAccessKey,
  81. securityToken: cred.sessionToken || cred.securityToken || '',
  82. endpoint: cred.endpoint || 'obs.cn-south-1.myhuaweicloud.com',
  83. bucket: cred.bucket || null,
  84. prefix: cred.prefix || '',
  85. };
  86. } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
  87. return null;
  88. }
  89. /**
  90. * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
  91. * 配置只写进临时目录(0700),命令结束立即删除——STS 不落盘留存、不进日志。
  92. *
  93. * @param {object} sts fetchStsCredentials() 的返回值
  94. * @param {string[]} args obsutil 参数(不含 -e/-i/-k/-t)
  95. */
  96. export function runWithSts(sts, args, timeout = 300000) {
  97. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
  98. fs.chmodSync(tmpDir, 0o700);
  99. const cfgFile = path.join(tmpDir, '.obsutilconfig');
  100. // STS 写入临时配置仅供 obsutil 本次调用读取,结束后立刻删除
  101. fs.writeFileSync(cfgFile, [
  102. `endpoint=${sts.endpoint}`,
  103. `ak=${sts.ak}`,
  104. `sk=${sts.sk}`,
  105. `token=${sts.securityToken}`,
  106. '',
  107. ].join('\n'), { mode: 0o600 });
  108. try {
  109. const r = spawnSync('obsutil', args, {
  110. encoding: 'utf8',
  111. timeout,
  112. env: { ...process.env, OBSUTIL_CONFIG_FILE: cfgFile },
  113. });
  114. const out = (r.stdout || '') + (r.stderr || '');
  115. return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
  116. } finally {
  117. try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
  118. }
  119. }
  120. /** 5 级凭据/配置解析:第0级 sessionToken 自举 → env → ~/.fmode → ./.fmode → obsutil 默认链 */
  121. export async function resolveStorageConfig() {
  122. const cfg = {
  123. obsUtilPath: 'obsutil',
  124. endpoint: 'obs.cn-south-1.myhuaweicloud.com',
  125. bucket: null,
  126. cdnDomain: null,
  127. sts: null, // 第0级自举得到的 STS(内存持有)
  128. via: null, // 命中的解析级别(诊断用,不含任何密钥)
  129. };
  130. // ---- 第0级自举:sessionToken → STS ----
  131. const sessionToken = resolveSessionToken();
  132. if (sessionToken) {
  133. const sts = await fetchStsCredentials(sessionToken);
  134. if (sts) {
  135. cfg.sts = sts;
  136. cfg.bucket = sts.bucket || cfg.bucket;
  137. cfg.endpoint = sts.endpoint || cfg.endpoint;
  138. cfg.via = 'level0:sessionToken->STS';
  139. return cfg; // 自举成功,无需回落
  140. }
  141. // sessionToken 存在但换取失败:给出明确报错指向重新登录
  142. console.error('sessionToken 存在但 STS 换取失败——sessionToken 缺失或失效,请重新登录 FMODE Studio 或配置 FMODE_SESSION_TOKEN');
  143. console.error('(将继续尝试第1-4级回落配置)');
  144. }
  145. // ---- 第1级:环境变量 ----
  146. if (process.env.FMODE_OBS_CONF) {
  147. try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); cfg.via = 'level1:FMODE_OBS_CONF'; } catch {}
  148. }
  149. // ---- 第2/3级:~/.fmode/config.json → ./.fmode/config.json ----
  150. for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
  151. try {
  152. const j = JSON.parse(fs.readFileSync(p, 'utf8'));
  153. if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
  154. if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = cfg.via || `level:${p}`; }
  155. if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
  156. if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
  157. } catch {}
  158. }
  159. // ---- 第4级:obsutil 默认链(已配置则探测 bucket)----
  160. if (!cfg.bucket) {
  161. try {
  162. const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
  163. const m = out.match(/obs:\/\/([^/]+)/);
  164. if (m) { cfg.bucket = m[1]; cfg.via = 'level4:obsutil-default'; }
  165. } catch {}
  166. }
  167. return cfg;
  168. }
  169. /** 公开 URL: cdnDomain 优先, 否则桶域名 */
  170. export function publicUrl(cfg, key) {
  171. if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
  172. return `https://${cfg.bucket}.${cfg.endpoint}/${key}`.replace('.myhuaweicloud.com', '.myhuaweicloud.com');
  173. }
  174. function obs(args, cfg, timeout = 120000) {
  175. // 第0级命中 → STS 一次性临时配置执行(不落盘留存)
  176. if (cfg.sts) return runWithSts(cfg.sts, args, timeout);
  177. // 回落:用户自己的 obsutil 配置
  178. const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
  179. const out = (r.stdout || '') + (r.stderr || '');
  180. return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
  181. }
  182. async function main() {
  183. const [cmd, ...rest] = process.argv.slice(2);
  184. const cfg = await resolveStorageConfig();
  185. if (!cfg.bucket && cmd !== 'help') {
  186. console.error('未解析到 bucket:请登录 FMODE Studio(sessionToken 自举)或配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
  187. console.error('(技能绝不内置密钥;自建 OBS 用户用 obsutil config 自行配置)');
  188. process.exit(2);
  189. }
  190. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  191. if (cmd === 'put') {
  192. const file = rest[0];
  193. let key = arg('--key');
  194. if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
  195. const acl = arg('--acl') || 'public-read';
  196. const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
  197. const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
  198. if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
  199. console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
  200. return;
  201. }
  202. if (cmd === 'setacl') {
  203. const key = arg('--key'); const acl = arg('--acl') || 'public-read';
  204. const recursive = rest.includes('-r');
  205. const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])], cfg);
  206. console.log(JSON.stringify({ ok: r.ok }, null, 2));
  207. return;
  208. }
  209. if (cmd === 'config') {
  210. // 诊断输出:绝不包含 AK/SK/SecurityToken/sessionToken 任何密钥本体
  211. console.log(JSON.stringify({
  212. via: cfg.via,
  213. bucket: cfg.bucket,
  214. endpoint: cfg.endpoint,
  215. cdnDomain: cfg.cdnDomain,
  216. stsBootstrapped: Boolean(cfg.sts),
  217. stsPrefix: cfg.sts ? cfg.sts.prefix : null,
  218. stsExpiresInMemoryOnly: Boolean(cfg.sts),
  219. }, null, 2));
  220. return;
  221. }
  222. console.log('用法: put | setacl | config');
  223. }
  224. main();