uploader.mjs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. #!/usr/bin/env node
  2. // Copyright (c) 未来飞马
  3. //
  4. // This Source Code Form is subject to the terms of the Mozilla Public
  5. // License, v. 2.0. If a copy of the MPL was not distributed with this
  6. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  7. //
  8. // Trademark Notice:
  9. // The MPL-2.0 license grants copyright permissions for source code only.
  10. // It does NOT grant any rights to use trademarks including "未来飞马",
  11. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  12. // Any use of these trademarks requires separate written permission.
  13. /**
  14. * fmode-storage uploader — 对象存储上传/公开链接/ACL
  15. * 零依赖(Node ≥18)。凭据按 5 级优先级自动解析,永不入库。
  16. *
  17. * v0.4.0(2026-09-23):新增**第0级 云函数预签名直传**(首选)。
  18. * 容器只需 FMODE_SESSION_TOKEN,平台云函数 fmodeagent-upload-url 持有 AK/SK 并
  19. * 签发 5 分钟预签名 PUT URL;对象键 user/<userid>/<ns>/<YYYYMM>/...,公网
  20. * https://s3.fmode.cn/<key>。解决"每个容器都要配 OBS AK/SK / storageProjectId"
  21. * 的落地缺口(此前 34 台容器无一具备,技能只能打印向导退出)。
  22. *
  23. * v0.3.0 诚实凭据链(真因修复,见 CHANGELOG.md):
  24. * 旧版第0级调用 POST /api/storage/credentials —— 该端点从未上线(404,
  25. * 设计文档 fmode-studio/docs/obs-cdn/04-API设计.md 状态为"规划中"),
  26. * "登录即可上传"是伪自举。本版改为:
  27. *
  28. * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
  29. * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
  30. * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
  31. * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)解析 AK/SK/endpoint/bucket
  32. * 第3级 sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud
  33. * → 项目隔离 STS(权威端点,生产实测 200;/api/storage/credentials
  34. * 设计端点从未上线——HEAD 探测 404 缓存于 .sts-probe.json)
  35. * 第4级 项目级 ./.fmode/config.json
  36. * 全失败:打印初始化向导指引并退出码 2,绝不伪装成功。
  37. *
  38. * 旧 sessionToken 自举保留为 --experimental-sts:仅当显式传入该参数
  39. * 且端点探测返回 200 时启用(面向未来端点上线,默认关闭)。
  40. *
  41. * 用法:
  42. * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
  43. * node uploader.mjs put <file> --key <objectKey> [--ns report] [--acl public-read] [--bucket ...]
  44. * node uploader.mjs setacl --key <prefix|key> --acl public-read [-r]
  45. * node uploader.mjs test
  46. * node uploader.mjs config
  47. */
  48. import fs from 'fs';
  49. import os from 'os';
  50. import path from 'path';
  51. import { execSync, spawnSync } from 'child_process';
  52. import { fileURLToPath } from 'url';
  53. const HOME = os.homedir();
  54. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  55. // fmode 网关基址(平台 STS 签发端点所在)
  56. const FMODE_API_BASE = process.env.FMODE_API_BASE || 'https://server.fmode.cn';
  57. const STORAGE_CREDENTIALS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/storage/credentials`;
  58. // 自举权威端点(2026-09-12 生产实测 200):sessionToken + projectId → 项目隔离 STS
  59. const DEPLOY_STS_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/apig/deploy/huaweicloud`;
  60. const DEFAULT_ENDPOINT = 'obs.cn-north-4.myhuaweicloud.com';
  61. const PROBE_CACHE_FILE = path.join(__dirname, '.sts-probe.json'); // 探测结果缓存(1 小时有效)
  62. const PROBE_TTL_MS = 60 * 60 * 1000;
  63. // ── 第0级(首选):云函数预签名直传 ─────────────────────────────────────────
  64. // fmodeagent-upload-url 云函数保管 AK/SK,按调用者身份签发 5 分钟有效的
  65. // OBS SigV2 预签名 PUT URL;容器只拿到 URL,密钥永不下发。
  66. // 对象键:user/<userid>/<namespace>/<YYYYMM>/<uuid>-<filename>,公网 https://s3.fmode.cn/<key>
  67. const UPLOAD_FN_URL = `${FMODE_API_BASE.replace(/\/$/, '')}/api/functions`;
  68. const UPLOAD_FN_ID = process.env.FMODE_UPLOAD_FN_ID || process.env.FMODE_STORAGE_FN_ID || 'AlP56LCKFm';
  69. const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
  70. /** 打印初始化向导(全链失败时的唯一出口,绝不伪装成功) */
  71. function printWizard() {
  72. console.error(`
  73. ┌─ fmode-storage 初始化向导 ─────────────────────────────────────────────┐
  74. │ 当前版本需一次性配置 OBS 子账号 AK/SK;sessionToken 免配置自举待平台 │
  75. │ 端点上线(设计文档 fmode-studio/docs/obs-cdn/04-API设计.md,规划中)。 │
  76. ├────────────────────────────────────────────────────────────────────────┤
  77. │ 步骤 1 向 Fmode 平台/管理员申请 OBS 子账号(邮件模板见 README.md │
  78. │ "三分钟初始化"章节),拿到 AK/SK 及所属桶/终端节点。 │
  79. │ 步骤 2 执行初始化(任选其一): │
  80. │ node uploader.mjs init --ak <AK> --sk <SK> \\ │
  81. │ --endpoint obs.cn-north-4.myhuaweicloud.com \\ │
  82. │ --bucket <bucket> │
  83. │ (省略参数则进入交互模式逐项询问) │
  84. │ 步骤 3 验证:node uploader.mjs test │
  85. │ (上传 1KB 探针文件→删除→报告成功) │
  86. └────────────────────────────────────────────────────────────────────────┘`);
  87. }
  88. // ============================================================================
  89. // 平台签发端点探测(B 节:诚实探测 + 1 小时缓存)
  90. // ============================================================================
  91. /**
  92. * HEAD 探测平台 STS 签发端点是否上线。结果缓存本文件 .sts-probe.json(1 小时)。
  93. * @returns {Promise<{online: boolean, checkedAt: number, status: number|null}>}
  94. */
  95. export async function probeStsEndpoint() {
  96. // 读缓存
  97. try {
  98. const j = JSON.parse(fs.readFileSync(PROBE_CACHE_FILE, 'utf8'));
  99. if (j.checkedAt && Date.now() - j.checkedAt < PROBE_TTL_MS) return j;
  100. } catch { /* 无缓存或损坏 → 重新探测 */ }
  101. let status = null;
  102. try {
  103. const res = await fetch(STORAGE_CREDENTIALS_URL, { method: 'HEAD', signal: AbortSignal.timeout(10000) });
  104. status = res.status;
  105. } catch { /* 网络失败视为未上线,不给假希望 */ }
  106. const result = { online: status === 200, checkedAt: Date.now(), status };
  107. try { fs.writeFileSync(PROBE_CACHE_FILE, JSON.stringify(result)); } catch { /* 只读环境忽略 */ }
  108. return result;
  109. }
  110. /** 探测缓存是否可以强制刷新(--refresh-probe / init / test 命令场景) */
  111. function invalidateProbeCache() {
  112. try { fs.rmSync(PROBE_CACHE_FILE, { force: true }); } catch { /* ignore */ }
  113. }
  114. // ============================================================================
  115. // 旧第0级自举(--experimental-sts 专用,端点上线前是死代码路径)
  116. // ============================================================================
  117. /**
  118. * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → user.json(登录流程写入的最新 token)
  119. * → ~/.fmode/config.json → ./.fmode/config.json
  120. * → /opt/data/fmode-identity.json → ~/.fmode-harness-agent/fmode-identity.json)。
  121. *
  122. * ⚠️ 最后两个 fmode-identity.json 是**兜底**,顺序刻意放在 .fmode/config 之后:
  123. * 容器普遍有 /opt/data/fmode-identity.json(开通时写入,字段名 session_token 下划线),
  124. * 而 ~/.fmode/config.json 不一定存在。不放兜底会导致「有身份文件却报缺 token」,
  125. * 技能只能打印初始化向导并退出。实测 34 台容器中 32 台只有该文件带 token。
  126. */
  127. export function resolveSessionToken() {
  128. if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
  129. const candidates = [
  130. path.join(HOME, '.fmode', 'config', 'user.json'),
  131. path.join(HOME, '.fmode', 'config.json'),
  132. path.join(process.cwd(), '.fmode', 'config.json'),
  133. '/opt/data/fmode-identity.json', // 兜底:容器开通时写入
  134. path.join(HOME, '.fmode-harness-agent', 'fmode-identity.json'), // 兜底:旧 harness 布局
  135. ];
  136. for (const p of candidates) {
  137. try {
  138. if (!fs.existsSync(p)) continue;
  139. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  140. const t = j.sessionToken || (j.user && j.user.sessionToken) || j.session_token || null;
  141. if (t && String(t).trim()) return String(t).trim();
  142. } catch { /* try next source */ }
  143. }
  144. return null;
  145. }
  146. /**
  147. * 解析 storage 专用 projectId(非密钥)。来源优先级:FMODE_STORAGE_PROJECT_ID 环境变量
  148. * → ~/.fmode/config.json / user.json 的 storageProjectId → ./.fmode/deploy.json 的 projectId。
  149. */
  150. export function resolveStorageProjectId() {
  151. if (process.env.FMODE_STORAGE_PROJECT_ID) return process.env.FMODE_STORAGE_PROJECT_ID.trim();
  152. const candidates = [
  153. path.join(HOME, '.fmode', 'config.json'),
  154. path.join(HOME, '.fmode', 'config', 'user.json'),
  155. path.join(process.cwd(), '.fmode', 'deploy.json'),
  156. ];
  157. for (const p of candidates) {
  158. try {
  159. if (!fs.existsSync(p)) continue;
  160. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  161. const id = j.storageProjectId || j.projectId || null;
  162. if (id && String(id).trim()) return String(id).trim();
  163. } catch { /* try next source */ }
  164. }
  165. return null;
  166. }
  167. /**
  168. * 第3级(权威,2026-09-12 生产实测 200):sessionToken + projectId → 项目隔离 STS。
  169. *
  170. * 权威链路(future-server api/api-ncloud/apig/routes-deploy.js):
  171. * POST /api/apig/deploy/huaweicloud {token, projectId}
  172. * → { code:200, data:{ accessKey, secretKey, securityToken, obsPath } }
  173. * → obsPath "obs://nova-cloud/dev/<projectId>/" 解析出 bucket/prefix
  174. * 服务端权限:token→用户身份 → Project.user / Project.owner / ProjectTeam 三查。
  175. * ⚠️ 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  176. */
  177. export async function fetchStsViaDeploy(sessionToken, projectId) {
  178. if (!sessionToken || !projectId) return null;
  179. try {
  180. const res = await fetch(DEPLOY_STS_URL, {
  181. method: 'POST',
  182. headers: { 'Content-Type': 'application/json' },
  183. body: JSON.stringify({ token: sessionToken, projectId }),
  184. signal: AbortSignal.timeout(20000),
  185. });
  186. if (!res.ok) return null;
  187. const body = await res.json().catch(() => null);
  188. const data = body && body.data;
  189. if (!body || body.code !== 200 || !data || !data.success) return null;
  190. const obsPath = String(data.obsPath || '');
  191. const m = obsPath.match(/^obs:\/\/([^/]+)\/(.*)\/$/);
  192. return {
  193. ak: String(data.accessKey || ''),
  194. sk: String(data.secretKey || ''),
  195. securityToken: String(data.securityToken || ''),
  196. expiresAt: data.expiresAt || null,
  197. projectId,
  198. // nova-cloud 桶在 cn-south-1(实测 2026-09-12;north-4 会 NoSuchBucket)
  199. endpoint: 'obs.cn-south-1.myhuaweicloud.com',
  200. bucket: m ? m[1] : 'nova-cloud',
  201. prefix: m ? m[2] + '/' : `dev/${projectId}/`,
  202. };
  203. } catch { return null; }
  204. }
  205. /**
  206. * 备用(任务书 B 节预留):设计文档端点 /api/storage/credentials。
  207. * ⚠️ 该端点从未上线(HEAD 探测 404),端点上线前必然返回 null。仅 --experimental-sts 时调用。
  208. * 返回的 STS 仅内存持有:不写文件、不打日志、不进错误信息。
  209. */
  210. export async function fetchStsCredentials(sessionToken) {
  211. if (!sessionToken) return null;
  212. try {
  213. const res = await fetch(STORAGE_CREDENTIALS_URL, {
  214. method: 'POST',
  215. headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sessionToken}` },
  216. body: JSON.stringify({ op: ['put', 'delete'] }),
  217. signal: AbortSignal.timeout(15000),
  218. });
  219. if (!res.ok) return null;
  220. const body = await res.json().catch(() => null);
  221. const cred = body && (body.credentials || body.data);
  222. if (!cred || !cred.accessKeyId || !cred.secretAccessKey) return null;
  223. return {
  224. ak: cred.accessKeyId,
  225. sk: cred.secretAccessKey,
  226. securityToken: cred.sessionToken || cred.securityToken || '',
  227. endpoint: cred.endpoint || DEFAULT_ENDPOINT,
  228. bucket: cred.bucket || null,
  229. prefix: cred.prefix || '',
  230. };
  231. } catch { return null; }
  232. }
  233. /** STS 签发作用域限定 key 必须在 prefix 内(dev/<projectId>/...),防越权路径 */
  234. export function scopedKey(cfg, key) {
  235. if (cfg.sts && cfg.sts.prefix) {
  236. const pfx = cfg.sts.prefix.endsWith('/') ? cfg.sts.prefix : cfg.sts.prefix + '/';
  237. if (key.startsWith(pfx)) return key;
  238. return pfx + key.replace(/^\/+/, '');
  239. }
  240. return key;
  241. }
  242. /**
  243. * 用 STS 临时凭证构造一次性 obsutil 配置目录,执行命令后即删。
  244. * (配置经 -config= 显式传给 obsutil——旧版 OBSUTIL_CONFIG_FILE 环境变量
  245. * obsutil 并不识别,属无效传递,本版修正。)
  246. */
  247. export function runWithSts(sts, args, timeout = 300000) {
  248. const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fmode-sts-'));
  249. fs.chmodSync(tmpDir, 0o700);
  250. const cfgFile = path.join(tmpDir, '.obsutilconfig');
  251. fs.writeFileSync(cfgFile, [
  252. `endpoint=${sts.endpoint}`,
  253. `ak=${sts.ak}`,
  254. `sk=${sts.sk}`,
  255. `token=${sts.securityToken}`,
  256. '',
  257. ].join('\n'), { mode: 0o600 });
  258. try {
  259. return runObsutil(cfg.obsUtilPath, args, { cfgFile, timeout });
  260. } finally {
  261. try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
  262. }
  263. }
  264. // ============================================================================
  265. // obsutil 执行层
  266. // ============================================================================
  267. /** 统一 obsutil 调用。成功判定用退出码 + 末行无 "failed",杜绝"failed"文案误判为成功。 */
  268. function runObsutil(obsUtilPath, args, { cfgFile = null, timeout = 120000 } = {}) {
  269. const r = spawnSync(obsUtilPath, [...args, ...(cfgFile ? [`-config=${cfgFile}`] : [])], {
  270. encoding: 'utf8',
  271. timeout,
  272. });
  273. const out = (r.stdout || '') + (r.stderr || '');
  274. const ok = r.status === 0 && !/failed/i.test(out.split('\n').filter(Boolean).pop() || '');
  275. return { ok, out, status: r.status };
  276. }
  277. /** 公开 URL: cdnDomain 优先, 否则桶域名 */
  278. export function publicUrl(cfg, key) {
  279. if (cfg.cdnDomain) return `https://${cfg.cdnDomain}/${key}`;
  280. return `https://${cfg.bucket}.${cfg.endpoint}/${key}`;
  281. }
  282. // ============================================================================
  283. // 第0级:云函数预签名直传(免 AK/SK,首选)
  284. // ============================================================================
  285. /**
  286. * 申请预签名 URL 并直传(云函数持有真实 AK/SK)。
  287. * @returns {{ok:true, key:string, url:string, bytes:number, via:string}|null}
  288. * null = 该通道不可用(无 sessionToken / 云函数未配置 / 网络失败),调用方回落下一级
  289. */
  290. // ── MIME 推断(v1.2.0,缺陷报告 P0)───────────────────────────────────────
  291. // 预签名 URL 的签名**绑定 Content-Type**,必须在申请时就把正确的 mimeType 传给云函数;
  292. // PUT 阶段再改头会 403 SignatureDoesNotMatch。不传则云函数默认 application/octet-stream,
  293. // 浏览器对 octet-stream 是「下载」而非「渲染」→ HTML 报告分享后无法直接浏览。
  294. const MIME_MAP = {
  295. '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
  296. '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
  297. '.mjs': 'application/javascript; charset=utf-8',
  298. '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
  299. '.md': 'text/markdown; charset=utf-8', '.csv': 'text/csv; charset=utf-8',
  300. '.xml': 'application/xml; charset=utf-8', '.svg': 'image/svg+xml',
  301. '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  302. '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
  303. '.pdf': 'application/pdf', '.zip': 'application/zip',
  304. '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
  305. '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
  306. '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
  307. '.doc': 'application/msword',
  308. '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  309. '.xls': 'application/vnd.ms-excel',
  310. '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  311. '.ppt': 'application/vnd.ms-powerpoint',
  312. '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  313. };
  314. /** 按扩展名推断 MIME;未知类型返回 application/octet-stream(保持旧行为) */
  315. export function guessMime(file) {
  316. return MIME_MAP[path.extname(String(file)).toLowerCase()] || 'application/octet-stream';
  317. }
  318. /**
  319. * --key 去重(缺陷报告 P1):云函数会自动补 `user/<调用者id>/` 前缀。
  320. * 若 key 里已经写了 `user/<某id>/...`,会变成双层 `user/<id>/user/<id>/...`。
  321. * 因为云函数在服务端强制把 key 挂到**调用者自己**的前缀下,剥离这段一定是安全且正确的。
  322. */
  323. export function normalizeKey(key) {
  324. const m = String(key || '').match(/^user\/[^/]+\/(.+)$/);
  325. if (m) {
  326. console.error(`[warn] --key 不应带 "user/<id>/" 前缀(云函数会自动补),已自动剥离:${key} → ${m[1]}`);
  327. return m[1];
  328. }
  329. return key;
  330. }
  331. export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null, mimeType = null) {
  332. const token = resolveSessionToken();
  333. if (!token) return null;
  334. let buf;
  335. try { buf = fs.readFileSync(file); } catch { return null; }
  336. const filename = name || path.basename(file);
  337. let res;
  338. try {
  339. res = await fetch(UPLOAD_FN_URL, {
  340. method: 'POST',
  341. headers: { 'Content-Type': 'application/json' },
  342. body: JSON.stringify({
  343. token, id: UPLOAD_FN_ID,
  344. params: {
  345. filename, size: buf.length, namespace, key: objectKey || undefined,
  346. ...(mimeType ? { mimeType } : {}),
  347. },
  348. }),
  349. signal: AbortSignal.timeout(20000),
  350. });
  351. } catch { return null; }
  352. if (!res.ok) return null;
  353. const body = await res.json().catch(() => null);
  354. if (!body || body.code !== 200 || !body.uploadUrl) return null;
  355. try {
  356. const put = await fetch(body.uploadUrl, { method: 'PUT', headers: body.headers || {}, body: buf });
  357. if (!put.ok) return null;
  358. } catch { return null; }
  359. return {
  360. ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`,
  361. bytes: buf.length, mimeType: mimeType || 'application/octet-stream',
  362. via: `level0:cloudfunc(${UPLOAD_FN_ID})`,
  363. };
  364. }
  365. // ============================================================================
  366. // 凭据链:诚实 4 级
  367. // ============================================================================
  368. /** 解析 obsutil config 文件内容 → {endpoint, ak, sk, token, bucket}。密钥不外泄,仅内部使用。 */
  369. function parseObsutilConfig(text) {
  370. const c = {};
  371. for (const line of String(text).split('\n')) {
  372. const m = line.match(/^(endpoint|ak|sk|token)=(.*)$/);
  373. if (m) c[m[1]] = m[2].trim();
  374. }
  375. return (c.ak && c.sk) ? c : null;
  376. }
  377. /** obsutil config 文件候选路径(OBSUTIL_CONFIG 环境变量优先,其次 ~/.obsutilconfig) */
  378. function obsutilConfigPaths() {
  379. const paths = [];
  380. if (process.env.OBSUTIL_CONFIG) paths.push(process.env.OBSUTIL_CONFIG);
  381. paths.push(path.join(HOME, '.obsutilconfig'));
  382. // getpwuid 的 home 可能与 $HOME 不同(容器常见):obsutil 实际按它解析默认配置
  383. try {
  384. const pwHome = os.userInfo().homedir;
  385. if (pwHome && pwHome !== HOME) paths.push(path.join(pwHome, '.obsutilconfig'));
  386. } catch { /* ignore */ }
  387. return paths;
  388. }
  389. /** 定位 obsutil 可执行文件:PATH → ~/.local/bin → ~/bin → getpwuid home/bin → /usr/local/bin */
  390. function findObsutil() {
  391. const candidates = [];
  392. for (const dir of (process.env.PATH || '').split(':')) {
  393. if (dir) candidates.push(path.join(dir, 'obsutil'));
  394. }
  395. for (const base of [path.join(HOME, '.local', 'bin'), path.join(HOME, 'bin')]) candidates.push(path.join(base, 'obsutil'));
  396. try {
  397. const pwHome = os.userInfo().homedir;
  398. if (pwHome && pwHome !== HOME) candidates.push(path.join(pwHome, 'bin', 'obsutil'));
  399. } catch { /* ignore */ }
  400. candidates.push('/usr/local/bin/obsutil', '/usr/bin/obsutil');
  401. for (const p of candidates) {
  402. try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
  403. }
  404. return 'obsutil'; // 交给 spawnSync 报"not found"
  405. }
  406. /**
  407. * 诚实 4 级凭据解析(命中即用,检测不到就换下一级):
  408. * 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET)
  409. * 第2级 obsutil config 文件(OBSUTIL_CONFIG 或 ~/.obsutilconfig)
  410. * 第3级 sessionToken + storageProjectId → deploy STS(端点探测见 fetchStsCredentials 注释)
  411. * 第4级 项目级 ./.fmode/config.json
  412. */
  413. export async function resolveStorageConfig({ experimentalSts = false } = {}) {
  414. const cfg = {
  415. obsUtilPath: 'obsutil',
  416. endpoint: DEFAULT_ENDPOINT,
  417. bucket: null,
  418. cdnDomain: null,
  419. sts: null,
  420. via: null, // 命中的解析级别(诊断用,不含任何密钥)
  421. ak: null, // 内部传递用,config 命令绝不输出
  422. sk: null,
  423. token: '',
  424. };
  425. cfg.obsUtilPath = findObsutil();
  426. // ---- 第1级:环境变量 ----
  427. if (process.env.OBS_AK && process.env.OBS_SK) {
  428. cfg.ak = process.env.OBS_AK.trim();
  429. cfg.sk = process.env.OBS_SK.trim();
  430. if (process.env.OBS_ENDPOINT) cfg.endpoint = process.env.OBS_ENDPOINT.trim();
  431. if (process.env.OBS_BUCKET) cfg.bucket = process.env.OBS_BUCKET.trim();
  432. cfg.via = 'level1:env(OBS_AK/OBS_SK)';
  433. }
  434. // ---- 第2级:obsutil config 文件 ----
  435. if (!cfg.ak) {
  436. for (const p of obsutilConfigPaths()) {
  437. try {
  438. if (!fs.existsSync(p)) continue;
  439. const c = parseObsutilConfig(fs.readFileSync(p, 'utf8'));
  440. if (c) {
  441. cfg.ak = c.ak; cfg.sk = c.sk; cfg.token = c.token || '';
  442. if (c.endpoint) cfg.endpoint = c.endpoint;
  443. cfg.via = `level2:obsutilconfig(${p})`;
  444. break;
  445. }
  446. } catch { /* try next path */ }
  447. }
  448. }
  449. // ---- 第3级:平台签发 STS(deploy 权威链路,2026-09-12 生产实测 200)----
  450. // sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud → 项目隔离 STS
  451. // (设计文档中的 /api/storage/credentials 从未上线——HEAD 探测 404,.sts-probe.json 缓存;
  452. // 端点若上线,见 fetchStsCredentials 的备用实现,仅 --experimental-sts 启用)
  453. if (!cfg.ak) {
  454. const sessionToken = resolveSessionToken();
  455. if (sessionToken) {
  456. const projectId = resolveStorageProjectId();
  457. const sts = projectId ? await fetchStsViaDeploy(sessionToken, projectId) : null;
  458. if (sts) {
  459. // deploy 端点已实测 200(区别于 storage/credentials 的伪自举),默认启用;
  460. // --experimental-sts 仅控制旧 storage/credentials 备用路径
  461. cfg.sts = sts;
  462. cfg.bucket = sts.bucket || cfg.bucket;
  463. cfg.endpoint = sts.endpoint || cfg.endpoint;
  464. cfg.cdnDomain = process.env.FMODE_CDN_DOMAIN || 'app.fmode.cn';
  465. cfg.via = 'level3:sessionToken+projectId->deploySTS';
  466. return cfg;
  467. }
  468. if (!projectId) {
  469. console.error('sessionToken 存在但缺少 storageProjectId——请设置 FMODE_STORAGE_PROJECT_ID 或在 ~/.fmode/config.json 配 storageProjectId(须为本人有权限的 Project objectId)');
  470. } else {
  471. console.error('sessionToken 存在但 STS 签发失败(/api/apig/deploy/huaweicloud 未 200)——token 失效或该 projectId 不属于当前用户');
  472. }
  473. console.error('(将继续尝试第4级回落配置)');
  474. } else {
  475. // 无 sessionToken:检查设计文档端点是否上线(任务书 B 节:诚实探测,缓存 1 小时)
  476. const probe = await probeStsEndpoint();
  477. if (probe.online && experimentalSts) {
  478. const token = resolveSessionToken() || process.env.FMODE_API_TOKEN || null;
  479. const sts = token ? await fetchStsCredentials(token) : null;
  480. if (sts) {
  481. cfg.sts = sts;
  482. cfg.bucket = sts.bucket || cfg.bucket;
  483. cfg.endpoint = sts.endpoint || cfg.endpoint;
  484. cfg.via = 'level3:experimental-storage-credentials';
  485. return cfg;
  486. }
  487. console.error('--experimental-sts 已启用且设计端点在线,但换取失败');
  488. } else if (probe.online) {
  489. console.error('平台 STS 设计端点已上线(/api/storage/credentials)。加 --experimental-sts 启用该自举路径,或继续用 deploy/obsutil 配置模式。');
  490. }
  491. }
  492. }
  493. // ---- 第4级:项目级 ./.fmode/config.json ----
  494. if (!cfg.ak && !cfg.sts) {
  495. try {
  496. const p = path.join(process.cwd(), '.fmode', 'config.json');
  497. const j = JSON.parse(fs.readFileSync(p, 'utf8'));
  498. if (j.obsUtilPath) cfg.obsUtilPath = j.obsUtilPath;
  499. if (j.obsBucket) { cfg.bucket = j.obsBucket; cfg.via = `level4:${p}`; }
  500. if (j.obsEndpoint) cfg.endpoint = j.obsEndpoint;
  501. if (j.cdnDomain) cfg.cdnDomain = j.cdnDomain;
  502. } catch { /* ignore */ }
  503. }
  504. // ---- bucket 补全:第1/2级命中但缺 bucket → obsutil ls 探测 ----
  505. if (cfg.ak && !cfg.bucket) {
  506. try {
  507. const out = execSync(`${JSON.stringify(cfg.obsUtilPath)} ls -limit=1`, { encoding: 'utf8', timeout: 15000 });
  508. const m = out.match(/obs:\/\/([^/\s]+)/);
  509. if (m) { cfg.bucket = m[1]; cfg.via += '+bucket-probe'; }
  510. } catch { /* 探测失败交给上层报错 */ }
  511. }
  512. return cfg;
  513. }
  514. // ============================================================================
  515. // 初始化向导(C 节:新用户路径)
  516. // ============================================================================
  517. /**
  518. * node uploader.mjs init [--ak ... --sk ... --endpoint ... --bucket ...]
  519. * 写入 obsutil config(600 权限)+ 本技能 config 段,然后立即 test。
  520. */
  521. async function cmdInit(rest) {
  522. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  523. const ask = async (q) => {
  524. process.stdout.write(q);
  525. let buf = '';
  526. for await (const chunk of process.stdin) { buf += chunk; break; }
  527. return buf.trim();
  528. };
  529. const ak = arg('--ak') || await ask('AK: ');
  530. const sk = arg('--sk') || await ask('SK: ');
  531. const endpoint = arg('--endpoint') || await ask(`endpoint [${DEFAULT_ENDPOINT}]: `) || DEFAULT_ENDPOINT;
  532. const bucket = arg('--bucket') || await ask('bucket: ');
  533. if (!ak || !sk || !bucket) {
  534. console.error('init 失败:AK/SK/bucket 均必填(--ak --sk --endpoint --bucket 或交互输入)');
  535. process.exit(2);
  536. }
  537. // 1) 写 obsutil config(600)
  538. const cfgPath = path.join(HOME, '.obsutilconfig');
  539. fs.writeFileSync(cfgPath, [
  540. `endpoint=${endpoint}`,
  541. `ak=${ak}`,
  542. `sk=${sk}`,
  543. 'token=',
  544. 'cname=false',
  545. `endpointCrr=http://your-endpoint`,
  546. `akCrr=*** Provide your Access Key ***`,
  547. `skCrr=*** Provide your Secret Key ***`,
  548. `tokenCrr=`,
  549. '',
  550. ].join('\n'), { mode: 0o600 });
  551. // 2) 写本技能 config 段(~/.fmode/config.json,不含密钥本体)
  552. const fmodeDir = path.join(HOME, '.fmode');
  553. const fmodeCfg = path.join(fmodeDir, 'config.json');
  554. fs.mkdirSync(fmodeDir, { recursive: true });
  555. let j = {};
  556. try { j = JSON.parse(fs.readFileSync(fmodeCfg, 'utf8')); } catch { /* 新文件 */ }
  557. j.obsBucket = bucket;
  558. j.obsEndpoint = endpoint;
  559. fs.writeFileSync(fmodeCfg, JSON.stringify(j, null, 2) + '\n', { mode: 0o600 });
  560. console.error(`已写入 ${cfgPath}(600)与 ${fmodeCfg}(技能 config 段,不含密钥本体)`);
  561. // 3) 立即 test:上传 1KB 探针 → 删除 → 报告
  562. return cmdTest();
  563. }
  564. /** test:上传 1KB 探针文件→删除→报告成功 */
  565. async function cmdTest() {
  566. // 第0级:云函数免密钥通道(首选),能通就直接成功
  567. const probeTmp = path.join(os.tmpdir(), `fmode-storage-cfprobe-${process.pid}.txt`);
  568. fs.writeFileSync(probeTmp, 'fmode-storage cloudfunc probe ' + new Date().toISOString() + '\n');
  569. try {
  570. const r = await putViaCloudFunction(probeTmp, null, 'selftest');
  571. if (r) {
  572. console.log(JSON.stringify({ ok: true, via: r.via, url: r.url, bytes: r.bytes, message: '云函数免密钥通道自检成功(本机无需任何 AK/SK)' }, null, 2));
  573. return true;
  574. }
  575. } finally {
  576. try { fs.rmSync(probeTmp, { force: true }); } catch { /* ignore */ }
  577. }
  578. const cfg = await resolveStorageConfig();
  579. if (!cfg.ak && !cfg.sts) { printWizard(); process.exit(2); }
  580. if (!cfg.bucket) { console.error(`凭据已命中(${cfg.via})但未解析到 bucket:请 --bucket 指定或用 init 写入`); process.exit(2); }
  581. const tmp = path.join(os.tmpdir(), `fmode-storage-probe-${process.pid}.txt`);
  582. fs.writeFileSync(tmp, 'fmode-storage probe ' + new Date().toISOString() + '\n');
  583. const key = `skill-storage-selftest/probe-${process.pid}.txt`;
  584. try {
  585. const up = runObsutil(cfg.obsUtilPath, ['cp', tmp, `obs://${cfg.bucket}/${key}`], { timeout: 120000 });
  586. if (!up.ok) { console.error('test 失败(上传):', up.out.slice(-300)); process.exit(1); }
  587. const del = runObsutil(cfg.obsUtilPath, ['rm', `obs://${cfg.bucket}/${key}`, '-f'], { timeout: 60000 });
  588. if (!del.ok) { console.error('test 失败(清理探针文件,上传本身已成功):', del.out.slice(-300)); process.exit(1); }
  589. console.log(JSON.stringify({ ok: true, bucket: cfg.bucket, endpoint: cfg.endpoint, via: cfg.via, probeKey: key, message: '上传 1KB 探针→删除 全部成功' }, null, 2));
  590. return true;
  591. } finally {
  592. try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
  593. }
  594. }
  595. // ============================================================================
  596. // main
  597. // ============================================================================
  598. async function main() {
  599. const [cmd, ...rest] = process.argv.slice(2);
  600. const arg = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : null; };
  601. const experimentalSts = rest.includes('--experimental-sts');
  602. if (rest.includes('--refresh-probe')) invalidateProbeCache();
  603. if (cmd === 'init') { process.exit(await cmdInit(rest) ? 0 : 1); }
  604. if (cmd === 'test') { process.exit(await cmdTest() ? 0 : 1); }
  605. // ── 第0级(首选,免密钥):put 直接走云函数预签名直传 ──────────────────
  606. // 必须在凭据链解析之前:否则"4 级全未命中 → 打印向导 exit 2"会把这条路挡住,
  607. // 而这条路恰恰是唯一不需要任何本机凭据的通道。
  608. if (cmd === 'put') {
  609. const file0 = rest[0];
  610. const key0raw = arg('--key');
  611. if (!file0 || !key0raw) { console.error('用法: put <file> --key <objectKey> [--ns report] [--mime text/html] [--acl public-read]'); process.exit(2); }
  612. if (!fs.existsSync(file0)) { console.error('文件不存在: ' + file0); process.exit(2); }
  613. const key0 = normalizeKey(key0raw); // 剥离误写的 user/<id>/ 前缀(P1)
  614. const ns0 = arg('--ns') || 'report';
  615. const mime0 = arg('--mime') || guessMime(file0); // 自动推断 MIME(P0)
  616. const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0), mime0);
  617. if (cf0) {
  618. console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes, mimeType: cf0.mimeType }, null, 2));
  619. console.error('[提示] 若覆盖了同名 key,CDN 缓存 TTL 为 30 天且缓存含 Content-Type 头;');
  620. console.error(' 分享出去的旧链接可能仍显示旧响应头。建议换新 key 路径(如 report-v2/index.html)发布。');
  621. return;
  622. }
  623. // 云函数不可用(无 sessionToken / 云函数未配置)→ 落到下面的 obsutil 4 级兜底
  624. }
  625. // setacl:云函数通道下对象在 PUT 时已带 x-obs-acl:public-read,无需单独设 ACL
  626. if (cmd === 'setacl' && resolveSessionToken()) {
  627. console.log(JSON.stringify({ ok: true, via: 'level0:cloudfunc', message: '云函数通道:对象在上传时即带上 x-obs-acl:public-read,无需再单独设置 ACL' }, null, 2));
  628. return;
  629. }
  630. const cfg = await resolveStorageConfig({ experimentalSts });
  631. if (!cfg.ak && !cfg.sts) {
  632. if (cmd !== 'help') {
  633. console.error('凭据链 4 级全部未命中(env → obsutil config → 平台签发(sessionToken+projectId) → 项目 config)。');
  634. printWizard();
  635. process.exit(2);
  636. }
  637. }
  638. if (cfg.ak && !cfg.sts && !cfg.bucket && ['put', 'setacl'].includes(cmd)) {
  639. console.error(`凭据已命中(${cfg.via})但未解析到 bucket:用 --bucket 指定,或重新执行 init 写入完整配置。`);
  640. process.exit(2);
  641. }
  642. const obs = (args, timeout = 120000) => cfg.sts
  643. ? runWithSts(cfg.sts, args, timeout)
  644. : runObsutil(cfg.obsUtilPath, args, { timeout });
  645. if (cmd === 'put') {
  646. const file = rest[0];
  647. let key = arg('--key');
  648. if (!file || !key) { console.error('用法: put <file> --key <objectKey> [--acl public-read]'); process.exit(2); }
  649. const acl = arg('--acl') || 'public-read';
  650. const bucketArg = arg('--bucket'); if (bucketArg) cfg.bucket = bucketArg;
  651. // 第0级(首选):云函数预签名直传 —— 容器不需要任何 AK/SK
  652. const ns = arg('--ns') || 'report';
  653. const cf = await putViaCloudFunction(file, key, ns, path.basename(key || file));
  654. if (cf) {
  655. console.log(JSON.stringify({ ok: true, key: cf.key, url: cf.url, bucket: 'storage-s3-nkkj', via: cf.via, bytes: cf.bytes }, null, 2));
  656. return;
  657. }
  658. key = scopedKey(cfg, key); // STS 签发时强制限定 dev/<projectId>/ 前缀,防越权路径
  659. const r = obs(['cp', file, `obs://${cfg.bucket}/${key}`, ...(acl ? ['-acl', acl] : [])]);
  660. if (!r.ok) { console.error('上传失败:', r.out.slice(-300)); process.exit(1); }
  661. console.log(JSON.stringify({ ok: true, key, url: publicUrl(cfg, key), bucket: cfg.bucket, via: cfg.via }, null, 2));
  662. return;
  663. }
  664. if (cmd === 'setacl') {
  665. const key = arg('--key'); const acl = arg('--acl') || 'public-read';
  666. const recursive = rest.includes('-r');
  667. const r = obs(['chattri', `obs://${cfg.bucket}/${key}`, '-acl', acl, ...(recursive ? ['-r', '-f'] : ['-f'])]);
  668. if (!r.ok) { console.error('setacl 失败:', r.out.slice(-300)); process.exit(1); }
  669. console.log(JSON.stringify({ ok: true }, null, 2));
  670. return;
  671. }
  672. if (cmd === 'config') {
  673. // 诊断输出:绝不包含 AK/SK/token/sessionToken 任何密钥本体
  674. console.log(JSON.stringify({
  675. via: cfg.via,
  676. bucket: cfg.bucket,
  677. endpoint: cfg.endpoint,
  678. cdnDomain: cfg.cdnDomain,
  679. credentialsResolved: Boolean(cfg.ak || cfg.sts),
  680. stsBootstrapped: Boolean(cfg.sts),
  681. stsPrefix: cfg.sts ? cfg.sts.prefix : null,
  682. stsProjectId: cfg.sts && cfg.sts.projectId ? cfg.sts.projectId : null,
  683. stsExpiresAt: cfg.sts && cfg.sts.expiresAt ? cfg.sts.expiresAt : null,
  684. }, null, 2));
  685. return;
  686. }
  687. console.log('用法: init | test | put | setacl | config');
  688. }
  689. main().catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });