gateway.mjs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. // Copyright (c) 未来飞马
  2. //
  3. // This Source Code Form is subject to the terms of the Mozilla Public
  4. // License, v. 2.0. If a copy of the MPL was not distributed with this
  5. // file, You can obtain one at https://mozilla.org/MPL/2.0/.
  6. //
  7. // Trademark Notice:
  8. // The MPL-2.0 license grants copyright permissions for source code only.
  9. // It does NOT grant any rights to use trademarks including "未来飞马",
  10. // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
  11. // Any use of these trademarks requires separate written permission.
  12. /**
  13. * fmode-storage 大文件接收网关(bigfile-gateway)— 零依赖(Node ≥18)。
  14. *
  15. * 场景:主通道(如企微入站 20MB)挡住大文件时,生成一个自包含接收页,用户浏览器
  16. * **直连对象存储**(预签名 PUT)把大文件传上来,再由 collect 取回。绕开聊天通道。
  17. *
  18. * 链路(全部复用 uploader.mjs 的会话语义,密钥永不落盘、永不进页面):
  19. * 1. resolveSessionToken() ← uploader.mjs
  20. * 2. 项目注册/复用 → projectId ← 云函数 /fmode/project-register
  21. * 3. 项目级 STS(1h) ← /api/apig/deploy/huaweicloud
  22. * 4. 自己实现 SigV4 预签名(不使用云函数签名——它恒定 282s,不可用于上传页)
  23. * 5. 渲染 templates/gateway.html → <outDir>/index.html
  24. * 6. 用同一 STS 把 index.html 传回项目前缀 → 公网 URL https://app.fmode.cn/dev/<pid>/bigfile/
  25. * 7. 生成 collect.sh / mint.json(不含密钥)供取回
  26. *
  27. * ⚠️ 关键事实(实测 2026-10-13,见 SKILL.md 踩坑库):
  28. * - STS 真身有效期 1h(不是签名标称的 48h)→ 用户第二天传必失败,须重新 mint+部署。
  29. * - bucket 是 nova-cloud,region=cn-south-1(north-4 会 NoSuchBucket)。
  30. * - STS 策略只允许 prefix=dev/<pid>/ 的 GetObject/PutObject,**不允许 List** → 取回靠逐槽位探测,不靠列举。
  31. * - 不存在对象的公网 URL 在 CDN 上可能回落成 200 + text/html(占位页)→ 判「到位」必须排除 text/html。
  32. */
  33. import fs from 'fs';
  34. import os from 'os';
  35. import path from 'path';
  36. import crypto from 'crypto';
  37. import { fileURLToPath } from 'url';
  38. import {
  39. resolveSessionToken,
  40. fetchStsViaDeploy,
  41. } from './uploader.mjs';
  42. const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
  43. const FUNCTIONS_URL = `${FMODE_API_BASE}/api/functions`;
  44. const DEPLOY_STS_URL = `${FMODE_API_BASE}/api/apig/deploy/huaweicloud`;
  45. const PROJECT_REGISTER_PATH = '/fmode/project-register';
  46. const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
  47. const SITE_BASE = (process.env.FMODE_SITE_BASE || 'https://app.fmode.cn').replace(/\/$/, '');
  48. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  49. const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'gateway.html');
  50. const DEFAULT_SLOTS = 20;
  51. const DEFAULT_TTL_HOURS = 1; // STS 真身上限即 1h,标本一致(踩坑 #2)
  52. const DEFAULT_PROJECT_SLUG = 'bigfile-gateway';
  53. const DEFAULT_ENDPOINT = 'obs.cn-south-1.myhuaweicloud.com';
  54. // ============================================================================
  55. // SigV4 预签名(自实现;query 签名,payload=UNSIGNED-PAYLOAD)
  56. // ============================================================================
  57. function _sha256hex(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
  58. function _hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest(); }
  59. /** RFC3986 转义(SigV4 要求:仅 A-Za-z0-9-_.~ 不转义) */
  60. function _rfc3986(str) {
  61. return encodeURIComponent(String(str)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
  62. }
  63. function _encodeKey(key) { return String(key).split('/').map(_rfc3986).join('/'); }
  64. /**
  65. * 华为云 OBS SigV4 预签名 URL。
  66. *
  67. * 只签 host(不签 content-type)——这样浏览器上传时可自由设置文件类型,
  68. * 且 x-obs-acl: public-read 走自定义头不会破坏签名(bucket 预检会回显请求头)。
  69. *
  70. * @param {object} o
  71. * @param {string} o.endpoint 如 obs.cn-south-1.myhuaweicloud.com
  72. * @param {string} o.region 如 cn-south-1(从 endpoint 推得)
  73. * @param {string} o.bucket
  74. * @param {string} o.key 对象键(不含前导 /)
  75. * @param {string} o.ak o.sk o.securityToken
  76. * @param {'PUT'|'GET'|'HEAD'} [o.method]
  77. * @param {number} [o.expires] 秒
  78. * @param {object} [o.query] 额外 query(如 list-type)
  79. * @returns {string} 预签名 URL
  80. */
  81. export function presignV4(o) {
  82. const now = o.now || new Date();
  83. const method = (o.method || 'PUT').toUpperCase();
  84. const host = `${o.bucket}.${o.endpoint}`;
  85. const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
  86. const dateStamp = amzDate.slice(0, 8);
  87. const scope = `${dateStamp}/${o.region}/s3/aws4_request`;
  88. const lower = { host };
  89. for (const [k, v] of Object.entries(o.headers || {})) lower[k.toLowerCase()] = String(v);
  90. const names = Object.keys(lower).sort();
  91. const canonicalHeaders = names.map((n) => `${n}:${lower[n].trim()}`).join('\n') + '\n';
  92. const signedHeaders = names.join(';');
  93. const q = {
  94. ...(o.query || {}),
  95. 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
  96. 'X-Amz-Credential': `${o.ak}/${scope}`,
  97. 'X-Amz-Date': amzDate,
  98. 'X-Amz-Expires': String(o.expires || DEFAULT_TTL_HOURS * 3600),
  99. 'X-Amz-SignedHeaders': signedHeaders,
  100. };
  101. if (o.securityToken) q['X-Amz-Security-Token'] = o.securityToken;
  102. const canonicalQuery = Object.keys(q).sort()
  103. .map((k) => `${_rfc3986(k)}=${_rfc3986(q[k])}`).join('&');
  104. const canonicalUri = '/' + _encodeKey(o.key || '');
  105. const payloadHash = 'UNSIGNED-PAYLOAD';
  106. const canonicalRequest = [method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join('\n');
  107. const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, _sha256hex(canonicalRequest)].join('\n');
  108. let k = _hmac(`AWS4${o.sk}`, dateStamp);
  109. k = _hmac(k, o.region); k = _hmac(k, 's3'); k = _hmac(k, 'aws4_request');
  110. const signature = crypto.createHmac('sha256', k).update(stringToSign).digest('hex');
  111. return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
  112. }
  113. /** 从 endpoint 推断 region:obs.cn-south-1.myhuaweicloud.com → cn-south-1 */
  114. export function regionOf(endpoint) {
  115. const m = String(endpoint || '').match(/obs\.([a-z0-9-]+)\./);
  116. return m ? m[1] : 'cn-south-1';
  117. }
  118. // ============================================================================
  119. // 项目注册 / STS
  120. // ============================================================================
  121. /** 注册或复用一个项目(云函数 /fmode/project-register,幂等:同名返回 reused:true)。 */
  122. export async function registerOrReuseProject(sessionToken, name) {
  123. if (!sessionToken || !name) return null;
  124. try {
  125. const res = await fetch(FUNCTIONS_URL, {
  126. method: 'POST', headers: { 'Content-Type': 'application/json' },
  127. body: JSON.stringify({ token: sessionToken, path: PROJECT_REGISTER_PATH, params: { name } }),
  128. signal: AbortSignal.timeout(20000),
  129. });
  130. if (!res.ok) return null;
  131. const j = await res.json().catch(() => null);
  132. if (!j || !j.success || !j.projectId) return null;
  133. return { projectId: String(j.projectId), projectName: String(j.projectName || name), reused: !!j.reused };
  134. } catch { return null; }
  135. }
  136. /**
  137. * 解析本次网关使用的 projectId:
  138. * --project-id > 环境变量 > 注册/复用 <projectSlug>。
  139. * 为什么用项目而非个人空间:项目级 STS 是唯一能拿到 **1h 有效期签名** 的通道
  140. * (云函数签名恒定 282s),而个人空间无对应 STS 签发端点。
  141. */
  142. export async function resolveGatewayProject(sessionToken, { projectId, projectSlug } = {}) {
  143. if (projectId) return { projectId, projectName: projectSlug || null, reused: null, via: 'explicit' };
  144. const slug = projectSlug || process.env.FMODE_GATEWAY_PROJECT || DEFAULT_PROJECT_SLUG;
  145. const reg = await registerOrReuseProject(sessionToken, slug);
  146. if (reg) return { ...reg, via: 'cloudfunc:project-register' };
  147. return null;
  148. }
  149. /** 用 sessionToken + projectId 取项目级 STS(1h)。仅内存持有。 */
  150. export async function mintSts(sessionToken, projectId) {
  151. const sts = await fetchStsViaDeploy(sessionToken, projectId);
  152. if (!sts) return null;
  153. // 兜底:deploy 端点返回的 obsPath 已解析出 bucket/prefix;endpoint 固定 south-1。
  154. return {
  155. ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
  156. bucket: sts.bucket || 'nova-cloud',
  157. endpoint: sts.endpoint || DEFAULT_ENDPOINT,
  158. prefix: sts.prefix || `dev/${projectId}/`,
  159. projectId,
  160. expiresAt: sts.expiresAt || null,
  161. };
  162. }
  163. // ============================================================================
  164. // 槽位(slot)
  165. // ============================================================================
  166. /**
  167. * 生成 N 个槽位。每个槽位 = 一个独立对象键 + 一条独立预签名 PUT URL + 一条公网读 URL。
  168. *
  169. * 为什么槽位化:浏览器拿不到本地文件名,槽位号是唯一的服务端信号 —— 既是写入位置,
  170. * 也是「哪个位子有文件」的判据。原作者注释说「原始文件名靠取回阶段命名规则补回来」,
  171. * 本实现做了增强:文件名在**上传时**由页面显式写进槽位的 Content-Disposition 无关;
  172. * 页面把 file.name 一起记入本地清单,取回时按 manifest 还原 —— 更贴近「按文件名取回」。
  173. */
  174. export function buildSlots({ sts, count, slotPrefix = '', expires }) {
  175. const slots = [];
  176. const base = `${sts.prefix}${slotPrefix ? slotPrefix.replace(/^\/+|\/+$/g, '') + '/' : ''}`;
  177. for (let i = 0; i < count; i++) {
  178. const n = String(i).padStart(4, '0');
  179. const key = `${base}${n}.bin`;
  180. const url = presignV4({
  181. ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
  182. endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
  183. key, method: 'PUT', expires,
  184. });
  185. slots.push({
  186. n,
  187. key,
  188. contentType: 'application/octet-stream',
  189. url,
  190. publicUrl: `${SITE_BASE}/${key}`,
  191. });
  192. }
  193. return slots;
  194. }
  195. // ============================================================================
  196. // 页面渲染 + 部署
  197. // ============================================================================
  198. /** 渲染接收页(模板 + 内联 CONFIG,自包含单文件)。 */
  199. export function renderPage({ title, channelName, limitText, limitNote, publicBase, slots, ttlHours }) {
  200. const tpl = fs.readFileSync(TEMPLATE_PATH, 'utf8');
  201. const cfg = { publicBase, ttlHours, slots: slots.map((s) => ({ n: s.n, name: `${s.n}.bin`, url: s.url, publicUrl: s.publicUrl, contentType: s.contentType })) };
  202. const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
  203. // 配置 JSON 内联进 <script type="application/json">:需转义 </ 以免提前闭合脚本标签
  204. const cfgJson = JSON.stringify(cfg).replace(/<\//g, '<\\/');
  205. return tpl
  206. .replace(/__TITLE__/g, esc(title))
  207. .replace(/__CHANNEL_NAME__/g, esc(channelName))
  208. .replace(/__LIMIT_TEXT__/g, esc(limitText))
  209. .replace(/__LIMIT_NOTE__/g, esc(limitNote))
  210. .replace(/__TTL_HOURS__/g, String(ttlHours))
  211. .replace(/__CONFIG_JSON__/g, cfgJson);
  212. }
  213. /**
  214. * 部署页面:用同一 STS 把 index.html 直传项目前缀(而不是塞进个人空间或独立站点项目)。
  215. * 好处:仍在 STS 允许的 dev/<pid>/ 前缀内,key 与预期一致(规避站点 dist-deploy 重写坑)。
  216. * 落手即 public-read,公网 https://app.fmode.cn/dev/<pid>/bigfile/index.html
  217. */
  218. export async function deployPage(sts, html) {
  219. const key = `${sts.prefix}bigfile/index.html`;
  220. const url = presignV4({
  221. ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
  222. endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
  223. key, method: 'PUT', expires: 3600,
  224. });
  225. const res = await fetch(url, {
  226. method: 'PUT',
  227. headers: { 'Content-Type': 'text/html; charset=utf-8', 'x-obs-acl': 'public-read' },
  228. body: html,
  229. signal: AbortSignal.timeout(30000),
  230. });
  231. if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
  232. return { ok: true, key, url: `${SITE_BASE}/${key}` };
  233. }
  234. // ============================================================================
  235. // collect(取回)
  236. // ============================================================================
  237. /**
  238. * 判「远端对象到位」:CDN 对不存在的路径可能回落 200 + text/html(占位页),
  239. * 因此仅凭 200 不足 —— 必须排除 text/html(踩坑 #1)。
  240. */
  241. export function looksLikeObject(status, contentType) {
  242. if (status !== 200) return false;
  243. const ct = String(contentType || '').toLowerCase();
  244. if (ct.includes('text/html')) return false;
  245. return true;
  246. }
  247. /**
  248. * 扫描槽位:对每个槽位的公网 URL 发 HEAD,判定是否已上传。
  249. * 不依赖 ListObjects(STS 策略不允许 List —— 实测 403)。
  250. */
  251. export async function scanSlots(manifest) {
  252. const found = [];
  253. for (const s of manifest.slots) {
  254. const url = `${SITE_BASE}/${s.key}`; // key 已含 dev/<pid>/ 前缀,勿再叠加 publicBase
  255. try {
  256. const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
  257. const ct = res.headers.get('content-type');
  258. const len = Number(res.headers.get('content-length') || 0);
  259. if (looksLikeObject(res.status, ct)) {
  260. found.push({ n: s.n, key: s.key, bytes: len, contentType: ct, publicUrl: url });
  261. }
  262. } catch { /* 单槽位失败不阻断整体扫描 */ }
  263. }
  264. return found;
  265. }
  266. // ============================================================================
  267. // 主流程
  268. // ============================================================================
  269. /** 读取当前用户显示名(用于页面标题;缺失则回落到项目名/id)。非机密。 */
  270. export function resolveDisplayName() {
  271. const candidates = [
  272. path.join(os.homedir(), '.fmode', 'config', 'user.json'),
  273. path.join(os.homedir(), '.fmode', 'config.json'),
  274. '/opt/data/fmode-identity.json',
  275. ];
  276. for (const p of candidates) {
  277. try {
  278. if (!fs.existsSync(p)) continue;
  279. const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
  280. const n = j.displayName || j.username || j.user?.displayName || j.user?.username || j.userid || j.userId;
  281. if (n && String(n).trim()) return String(n).trim();
  282. } catch { /* next */ }
  283. }
  284. return null;
  285. }
  286. /**
  287. * mint:领 STS → 建槽位 → 渲染 → 部署 → 写 mint.json / collect.sh。
  288. * @returns {Promise<{ok:boolean, ...}>}
  289. */
  290. export async function gatewayMint(opts = {}) {
  291. const outDir = path.resolve(opts.outDir || process.cwd());
  292. const slotsCount = Number(opts.slots || DEFAULT_SLOTS);
  293. const ttlHours = Number(opts.hours || DEFAULT_TTL_HOURS);
  294. const channelName = opts.channel || '企微入站(用户 → 我)';
  295. const limitText = opts.limit || '20MB';
  296. const limitNote = opts.note || `${channelName} 是可协商不掉的上游硬限,文件在到达我这一侧之前就被拒收;改本地上限无效。`;
  297. const sessionToken = resolveSessionToken();
  298. if (!sessionToken) {
  299. return { ok: false, error: 'no_session_token', message: '未找到登录态 sessionToken(云函数/STS 通道均依赖它)' };
  300. }
  301. const proj = await resolveGatewayProject(sessionToken, { projectId: opts.projectId, projectSlug: opts.projectSlug });
  302. if (!proj) return { ok: false, error: 'project_unresolved', message: '项目注册/解析失败(云函数 /fmode/project-register 未成功)' };
  303. const sts = await mintSts(sessionToken, proj.projectId);
  304. if (!sts) {
  305. return { ok: false, error: 'sts_failed', message: `项目级 STS 签发失败(项目 ${proj.projectId} 无权限或 token 失效)` };
  306. }
  307. const slotPrefix = opts.slotPrefix != null ? opts.slotPrefix : `gateway/${Date.now()}`;
  308. const slots = buildSlots({ sts, count: slotsCount, slotPrefix, expires: Math.min(ttlHours * 3600, 3600) });
  309. const title = opts.title || `大文件接收网关 · ${resolveDisplayName() || proj.projectName || proj.projectId}`;
  310. const html = renderPage({
  311. title, channelName, limitText, limitNote,
  312. publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
  313. slots, ttlHours,
  314. });
  315. fs.mkdirSync(outDir, { recursive: true });
  316. const indexPath = path.join(outDir, 'index.html');
  317. fs.writeFileSync(indexPath, html, 'utf8');
  318. const manifest = {
  319. v: 1,
  320. kind: 'fmode-bigfile-gateway',
  321. createdAt: new Date().toISOString(),
  322. ttlHours,
  323. projectId: proj.projectId,
  324. projectName: proj.projectName || null,
  325. bucket: sts.bucket,
  326. endpoint: sts.endpoint,
  327. region: regionOf(sts.endpoint),
  328. prefix: sts.prefix,
  329. publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
  330. slotPrefix,
  331. slots: slots.map((s) => ({ n: s.n, key: s.key, name: `${s.n}.bin` })),
  332. indexPath,
  333. };
  334. // mint.json 只含非机密信息(projectId / key 列表)——签名 URL 有 1h 时效且随 STS 失效,不入盘。
  335. const manifestPath = path.join(outDir, 'mint.json');
  336. fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
  337. // 部署页面(可 --no-deploy 跳过,仅本地生成)
  338. let deployed = null;
  339. if (!opts.noDeploy) {
  340. deployed = await deployPage(sts, html);
  341. }
  342. // collect 包装脚本(取回用;内部再 mint 新鲜 STS,不落盘任何密钥)
  343. const collectSh = path.join(outDir, 'collect.sh');
  344. fs.writeFileSync(collectSh, renderCollectSh(__dirname), { mode: 0o755 });
  345. const report = {
  346. ok: true,
  347. pageUrl: deployed && deployed.ok ? deployed.url : null,
  348. pageKey: deployed && deployed.ok ? deployed.key : null,
  349. deployError: deployed && !deployed.ok ? `${deployed.status} ${deployed.body}` : null,
  350. slots: slotsCount,
  351. ttlHours,
  352. projectId: proj.projectId,
  353. projectName: proj.projectName || null,
  354. projectVia: proj.via,
  355. prefix: sts.prefix,
  356. publicBase: manifest.publicBase,
  357. stsExpiresAt: sts.expiresAt,
  358. indexPath,
  359. manifestPath,
  360. collectScript: collectSh,
  361. collectHint: `bash ${collectSh} --list # 只看哪些槽位已有文件\nbash ${collectSh} --out ~/inbox # 下载到 ~/inbox 并登记台账`,
  362. stsExpiresNote: `STS 真身有效期约 ${ttlHours}h;用户过时未传须重新 mint+部署(不可沿用旧页)`,
  363. };
  364. return report;
  365. }
  366. function renderCollectSh(scriptsDir) {
  367. return `#!/usr/bin/env bash
  368. # 大文件网关取回器 —— 扫描到位文件 → 下载 → 台账。
  369. # 不落盘任何密钥:每次运行重新 mint 项目级 STS(1h),只输出预签名 GET URL。
  370. # 用法:
  371. # bash collect.sh --list # 只列出已到位的槽位
  372. # bash collect.sh --out ~/inbox # 下载到 ~/inbox 并写 ledger.jsonl
  373. # bash collect.sh --manifest mint.json --out ./received
  374. set -euo pipefail
  375. DIR="$(cd "$(dirname "$0")" && pwd)"
  376. NODE_BIN="\${NODE_BIN:-node}"
  377. exec "$NODE_BIN" "${scriptsDir}/gateway.mjs" collect --manifest "$DIR/mint.json" "$@"
  378. `;
  379. }
  380. // ============================================================================
  381. // CLI
  382. // ============================================================================
  383. function parseArgs(rest) {
  384. const a = { _: [] };
  385. for (let i = 0; i < rest.length; i++) {
  386. const t = rest[i];
  387. if (t.startsWith('--')) { const k = t.slice(2); const v = rest[i + 1]; if (v === undefined || v.startsWith('--')) { a[k] = true; } else { a[k] = v; i++; } }
  388. else a._.push(t);
  389. }
  390. return a;
  391. }
  392. async function cmdMint(rest) {
  393. const a = parseArgs(rest);
  394. const outDir = a.out || a._[0] || path.join(process.cwd(), 'gateway-out');
  395. const report = await gatewayMint({
  396. outDir,
  397. slots: a.slots,
  398. hours: a.hours,
  399. title: a.title,
  400. channel: a.channel,
  401. limit: a.limit,
  402. note: a.note,
  403. slotPrefix: a['slot-prefix'],
  404. projectId: a['project-id'],
  405. projectSlug: a['project-slug'],
  406. noDeploy: !!a['no-deploy'],
  407. });
  408. if (!report.ok) { console.error(JSON.stringify(report, null, 2)); process.exit(2); }
  409. if (a.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
  410. console.log('大文件接收网关已生成');
  411. console.log(' 页面 URL :', report.pageUrl || `(未部署:${report.deployError})`);
  412. console.log(' 本地文件 :', report.indexPath);
  413. console.log(' 槽位数 :', report.slots, ' 有效期约:', report.ttlHours + 'h');
  414. console.log(' 项目 :', report.projectId, `(${report.projectVia})`);
  415. console.log(' 前缀 :', report.prefix);
  416. console.log(' 取回 :', report.collectHint.replace(/\n/g, '\n '));
  417. console.log(' ⚠️ 发用户前先真传一个小文件实测(本工具不改这条铁律)。');
  418. return 0;
  419. }
  420. async function cmdCollect(rest) {
  421. const a = parseArgs(rest);
  422. const manifestPath = a.manifest || path.join(process.cwd(), 'mint.json');
  423. if (!fs.existsSync(manifestPath)) { console.error('找不到 manifest:', manifestPath); process.exit(2); }
  424. const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
  425. const outDir = a.out ? path.resolve(a.out) : null;
  426. console.log(`扫描槽位(${manifest.slots.length} 个)…`);
  427. const found = await scanSlots(manifest);
  428. if (found.length === 0) {
  429. console.log('没有检测到任何已到位的文件。');
  430. console.log('(若用户尚未上传,属正常;STS 过期需重新 mint+部署)');
  431. return 0;
  432. }
  433. console.log(`检测到 ${found.length} 个已到位文件:`);
  434. for (const f of found) console.log(` [${f.n}] ${f.key} ${f.bytes} B ${f.contentType}`);
  435. if (!outDir) { return 0; } // --list 行为
  436. const sessionToken = resolveSessionToken();
  437. const sts = await mintSts(sessionToken, manifest.projectId);
  438. if (!sts) { console.error('取回失败:无法 mint 新鲜 STS(sessionToken 失效或项目无权限)'); process.exit(2); }
  439. fs.mkdirSync(outDir, { recursive: true });
  440. const ledgerPath = path.join(outDir, 'ledger.jsonl');
  441. let ok = 0;
  442. for (const f of found) {
  443. const url = presignV4({
  444. ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
  445. endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
  446. key: f.key, method: 'GET', expires: 3600,
  447. });
  448. try {
  449. const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
  450. if (!looksLikeObject(res.status, res.headers.get('content-type'))) { console.error(` 跳过 [${f.n}](非对象响应)`); continue; }
  451. const buf = Buffer.from(await res.arrayBuffer());
  452. const local = path.join(outDir, `${f.n}.bin`);
  453. fs.writeFileSync(local, buf);
  454. fs.appendFileSync(ledgerPath, JSON.stringify({ n: f.n, key: f.key, bytes: buf.length, contentType: f.contentType, localPath: local, at: new Date().toISOString() }) + '\n');
  455. console.log(` 下载 [${f.n}] → ${local} (${buf.length} B)`);
  456. ok++;
  457. } catch (e) { console.error(` 下载 [${f.n}] 失败: ${e.message}`); }
  458. }
  459. console.log(`完成:${ok}/${found.length} 已下载到 ${outDir};台账 ${ledgerPath}`);
  460. return 0;
  461. }
  462. export async function main(argv = process.argv.slice(2)) {
  463. const [cmd, ...rest] = argv;
  464. if (cmd === 'mint' || cmd === 'generate' || cmd === 'gen') return cmdMint(rest);
  465. if (cmd === 'collect' || cmd === 'fetch') return cmdCollect(rest);
  466. console.log(`用法(大文件接收网关):
  467. node gateway.mjs mint [--slots 20] [--hours 1] [--out DIR] [--title T]
  468. [--channel "企微入站"] [--limit 20MB] [--note "..."]
  469. [--project-id ID | --project-slug slug] [--slot-prefix P]
  470. [--no-deploy] [--json]
  471. node gateway.mjs collect --manifest mint.json [--out DIR] # 无 --out 即 --list`);
  472. return cmd === 'help' ? 0 : 2;
  473. }
  474. // 作为脚本直接运行时才执行(被 import 时不自动跑,避免干扰 uploader.mjs)
  475. if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
  476. main().then((c) => process.exit(c)).catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
  477. }