uploader.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env node
  2. /**
  3. * fmode-storage uploader — 对象存储上传/公开链接/ACL
  4. * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
  5. *
  6. * 第0级(自举):sessionToken + projectId → POST /api/apig/deploy/huaweicloud 换 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. // 自举(权威,2026-09-12 生产实测 200):sessionToken + projectId → STS
  23. // POST /api/apig/deploy/huaweicloud → {accessKey, secretKey, securityToken, obsPath}
  24. // obsPath 形如 obs://nova-cloud/dev/<projectId>/(bucket=nova-cloud, prefix=dev/<projectId>/)
  25. // 服务端权限链: token→用户身份 → Project.user/owner 或 ProjectTeam 三查 → 华为云委托 Agency 签发 STS
  26. // 注意: 旧设计中的 POST /api/storage/credentials 端点在生产**不存在**(Cannot POST 404),已移除。
  27. const DEPLOY_STS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/apig/deploy/huaweicloud`;
  28. /**
  29. * 解析 storage 专用 projectId(非密钥)。
  30. * 来源优先级:FMODE_STORAGE_PROJECT_ID 环境变量
  31. * → ~/.fmode/config.json 的 storageProjectId
  32. * → ~/.fmode/config/user.json 的 storageProjectId
  33. * → ./.fmode/deploy.json 的 projectId(当前项目自己的发布身份)
  34. */
  35. export function resolveStorageProjectId() {
  36. if (process.env.FMODE_STORAGE_PROJECT_ID) return process.env.FMODE_STORAGE_PROJECT_ID.trim();
  37. const candidates = [
  38. path.join(HOME, '.fmode', 'config.json'),
  39. path.join(HOME, '.fmode', 'config', 'user.json'),
  40. path.join(process.cwd(), '.fmode', 'deploy.json'),
  41. ];
  42. for (const p of candidates) {
  43. try {
  44. if (!fs.existsSync(p)) continue;
  45. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  46. const id = j.storageProjectId || j.projectId || null;
  47. if (id && String(id).trim()) return String(id).trim();
  48. } catch { /* try next source */ }
  49. }
  50. return null;
  51. }
  52. /**
  53. * 第0级:解析 sessionToken。
  54. * 来源(优先级):FMODE_SESSION_TOKEN 环境变量
  55. * → ~/.fmode/config.json 的 sessionToken / user.sessionToken
  56. * → ~/.fmode/config/user.json 的 sessionToken
  57. * 找到即返回字符串,找不到返回 null(不报错——交给上层决定是否回落)。
  58. */
  59. export function resolveSessionToken() {
  60. if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
  61. // user.json 优先:登录流程(07-git「平台 sessionToken 失效恢复」)写入的最新 token
  62. const candidates = [
  63. path.join(HOME, '.fmode', 'config', 'user.json'),
  64. path.join(HOME, '.fmode', 'config.json'),
  65. path.join(process.cwd(), '.fmode', 'config.json'),
  66. ];
  67. for (const p of candidates) {
  68. try {
  69. if (!fs.existsSync(p)) continue;
  70. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  71. const t = j.sessionToken || (j.user && j.user.sessionToken) || null;
  72. if (t && String(t).trim()) return String(t).trim();
  73. } catch { /* try next source */ }
  74. }
  75. return null;
  76. }
  77. /**
  78. * 第0级:sessionToken + projectId → STS 临时凭证(AK/SK/SecurityToken,作用域限定项目 prefix)。
  79. *
  80. * 权威链路(future-server api/api-ncloud/apig/routes-deploy.js,2026-09-12 生产实测 200):
  81. * POST /api/apig/deploy/huaweicloud {token, projectId}
  82. * → { code:200, data:{ accessKey, secretKey, securityToken, obsPath } }
  83. * → obsPath "obs://nova-cloud/dev/<projectId>/" 解析出 bucket/prefix
  84. * → 客户端直传 OBS
  85. * 服务端权限:token→用户身份 → Project.user / Project.owner / ProjectTeam 三查。
  86. *
  87. * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  88. *
  89. * @returns {Promise<{ ak: string, sk: string, securityToken: string,
  90. * endpoint: string, bucket: string, prefix: string } | null>}
  91. * 签发失败返回 null(调用方回落第1-4级)。
  92. */
  93. export async function fetchStsCredentials(sessionToken, projectId) {
  94. if (!sessionToken || !projectId) return null;
  95. try {
  96. const res = await fetch(DEPLOY_STS_URL, {
  97. method: 'POST',
  98. headers: { 'Content-Type': 'application/json' },
  99. body: JSON.stringify({ token: sessionToken, projectId }),
  100. signal: AbortSignal.timeout(20000),
  101. });
  102. if (!res.ok) return null;
  103. const body = await res.json().catch(() => null);
  104. const data = body && body.data;
  105. if (!body || body.code !== 200 || !data || !data.success) return null;
  106. const obsPath = String(data.obsPath || '');
  107. const m = obsPath.match(/^obs:\/\/([^/]+)\/(.*)\/$/);
  108. return {
  109. ak: String(data.accessKey || ''),
  110. sk: String(data.secretKey || ''),
  111. securityToken: String(data.securityToken || ''),
  112. expiresAt: data.expiresAt || null,
  113. endpoint: 'obs.cn-south-1.myhuaweicloud.com', // nova-cloud 桶在 cn-south-1(实测 2026-09-12; north-4 会 NoSuchBucket)
  114. bucket: m ? m[1] : 'nova-cloud',
  115. prefix: m ? m[2] + '/' : `dev/${projectId}/`,
  116. };
  117. } catch { /* 网络/签名失败一律回落,不泄露错误细节 */ }
  118. return null;
  119. }
  120. /**
  121. * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
  122. * 配置只写进临时目录(0700),命令结束立即删除——STS 不落盘留存、不进日志。
  123. *
  124. * @param {object} sts fetchStsCredentials() 的返回值
  125. * @param {string[]} args obsutil 参数(不含 -e/-i/-k/-t)
  126. */
  127. export function runWithSts(sts, args, timeout = 300000) {
  128. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
  129. fs.chmodSync(tmpDir, 0o700);
  130. const cfgFile = path.join(tmpDir, '.obsutilconfig');
  131. // STS 写入临时配置仅供 obsutil 本次调用读取,结束后立刻删除
  132. fs.writeFileSync(cfgFile, [
  133. `endpoint=${sts.endpoint}`,
  134. `ak=${sts.ak}`,
  135. `sk=${sts.sk}`,
  136. `token=${sts.securityToken}`,
  137. '',
  138. ].join('\n'), { mode: 0o600 });
  139. try {
  140. // obsutil 只认 -config=<file> 参数(不认 OBSUTIL_CONFIG_FILE 环境变量——
  141. // 2026-09-12 实测:环境变量方式会静默回落默认配置导致 NoSuchBucket)。
  142. // 桶级命令在位置参数后追加 -config;endpoint 已写进 config 文件。
  143. const withCfg = [...args, '-config', cfgFile];
  144. const r = spawnSync('obsutil', withCfg, { encoding: 'utf8', timeout });
  145. const out = (r.stdout || '') + (r.stderr || '');
  146. return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
  147. } finally {
  148. try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
  149. }
  150. }
  151. /** 5 级凭据/配置解析:第0级 sessionToken 自举 → env → ~/.fmode → ./.fmode → obsutil 默认链 */
  152. export async function resolveStorageConfig() {
  153. const cfg = {
  154. obsUtilPath: 'obsutil',
  155. endpoint: 'obs.cn-south-1.myhuaweicloud.com',
  156. bucket: null,
  157. cdnDomain: null,
  158. sts: null, // 第0级自举得到的 STS(内存持有)
  159. via: null, // 命中的解析级别(诊断用,不含任何密钥)
  160. };
  161. // ---- 第0级自举(权威):sessionToken + projectId → deploy/huaweicloud → STS ----
  162. const sessionToken = resolveSessionToken();
  163. if (sessionToken) {
  164. const projectId = resolveStorageProjectId();
  165. const sts = projectId ? await fetchStsCredentials(sessionToken, projectId) : null;
  166. if (sts) {
  167. cfg.sts = sts;
  168. cfg.bucket = sts.bucket || cfg.bucket;
  169. cfg.endpoint = sts.endpoint || cfg.endpoint;
  170. cfg.cdnDomain = process.env.FMODE_CDN_DOMAIN || 'app.fmode.cn';
  171. cfg.via = 'level0:sessionToken+projectId->deploySTS';
  172. return cfg; // 自举成功,无需回落
  173. }
  174. // 自举未命中:给出可操作的明确指引
  175. if (!projectId) {
  176. console.error('sessionToken 存在但缺少 storageProjectId——请设置 FMODE_STORAGE_PROJECT_ID 或在 ~/.fmode/config.json 配 storageProjectId(须为本人有权限的 Project objectId)');
  177. } else {
  178. console.error('sessionToken 存在但 STS 签发失败(/api/apig/deploy/huaweicloud 未 200)——token 失效或该 projectId 不属于当前用户(服务端校验 Project.user/owner/ProjectTeam 三查)');
  179. }
  180. console.error('(将继续尝试第1-4级回落配置)');
  181. }
  182. // ---- 第1级:环境变量 ----
  183. if (process.env.FMODE_OBS_CONF) {
  184. try { Object.assign(cfg, JSON.parse(fs.readFileSync(process.env.FMODE_OBS_CONF, 'utf8'))); cfg.via = 'level1:FMODE_OBS_CONF'; } catch {}
  185. }
  186. // ---- 第2/3级:~/.fmode/config.json → ./.fmode/config.json ----
  187. for (const p of [path.join(HOME, '.fmode', 'config.json'), path.join(process.cwd(), '.fmode', 'config.json')]) {
  188. try {
  189. const j = JSON.parse(fs.readFileSync(p, 'utf8'));
  190. if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
  191. if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = cfg.via || `level:${p}`; }
  192. if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
  193. if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
  194. } catch {}
  195. }
  196. // ---- 第4级:obsutil 默认链(已配置则探测 bucket)----
  197. if (!cfg.bucket) {
  198. try {
  199. const out = execSync(`${cfg.obsUtilPath} ls -limit=1 2>/dev/null`, { encoding: 'utf8', timeout: 15000 });
  200. const m = out.match(/obs:\/\/([^/]+)/);
  201. if (m) { cfg.bucket = m[1]; cfg.via = 'level4:obsutil-default'; }
  202. } catch {}
  203. }
  204. return cfg;
  205. }
  206. /** 公开 URL: cdnDomain 优先, 否则桶域名 */
  207. export function publicUrl(cfg, key) {
  208. if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
  209. return `https://${cfg.bucket}.${cfg.endpoint}/${key}`.replace('.myhuaweicloud.com', '.myhuaweicloud.com');
  210. }
  211. /** v2 STS 的 key 必须限定在签发的 prefix 内(dev/<projectId>/...),防止越权路径 */
  212. function scopedKey(cfg, key) {
  213. if (cfg.sts && cfg.sts.prefix) {
  214. const pfx = cfg.sts.prefix.endsWith('/') ? cfg.sts.prefix : cfg.sts.prefix + '/';
  215. if (key.startsWith(pfx)) return key;
  216. return pfx + key.replace(/^\/+/, '');
  217. }
  218. return key;
  219. }
  220. function obs(args, cfg, timeout = 120000) {
  221. // 第0级命中 → STS 一次性临时配置执行(不落盘留存)
  222. if (cfg.sts) return runWithSts(cfg.sts, args, timeout);
  223. // 回落:用户自己的 obsutil 配置
  224. const r = spawnSync(cfg.obsUtilPath, [...args, '-e', cfg.endpoint], { encoding: 'utf8', timeout });
  225. const out = (r.stdout || '') + (r.stderr || '');
  226. return { ok: /Upload successfully|Download successfully|Set the acl/i.test(out) || r.status === 0, out };
  227. }
  228. async function main() {
  229. const [cmd, ...rest] = process.argv.slice(2);
  230. const cfg = await resolveStorageConfig();
  231. if (!cfg.bucket && cmd !== 'help') {
  232. console.error('未解析到 bucket:请登录 FMODE Studio(sessionToken 自举)或配置 ~/.fmode/config.json 的 obsBucket/obsEndpoint 或 obsutil config。');
  233. console.error('(技能绝不内置密钥;自建 OBS 用户用 obsutil config 自行配置)');
  234. process.exit(2);
  235. }
  236. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  237. if (cmd === 'put') {
  238. const file = rest[0];
  239. let key = arg('--key');
  240. if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
  241. const acl = arg('--acl') || 'public-read';
  242. const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
  243. key = scopedKey(cfg, key); // v2 STS: 强制限定 dev/<projectId>/ 前缀
  244. const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`], cfg);
  245. if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
  246. console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
  247. return;
  248. }
  249. if (cmd === 'setacl') {
  250. const key = arg('--key'); const acl = arg('--acl') || 'public-read';
  251. const recursive = rest.includes('-r');
  252. const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])], cfg);
  253. console.log(JSON.stringify({ ok: r.ok }, null, 2));
  254. return;
  255. }
  256. if (cmd === 'config') {
  257. // 诊断输出:绝不包含 AK/SK/SecurityToken/sessionToken 任何密钥本体
  258. console.log(JSON.stringify({
  259. via: cfg.via,
  260. bucket: cfg.bucket,
  261. endpoint: cfg.endpoint,
  262. cdnDomain: cfg.cdnDomain,
  263. stsBootstrapped: Boolean(cfg.sts),
  264. stsPrefix: cfg.sts ? cfg.sts.prefix : null,
  265. stsProjectId: cfg.sts && cfg.sts.projectId ? cfg.sts.projectId : null,
  266. stsExpiresAt: cfg.sts && cfg.sts.expiresAt ? cfg.sts.expiresAt : null,
  267. }, null, 2));
  268. return;
  269. }
  270. console.log('用法: put | setacl | config');
  271. }
  272. main();