| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523 |
- // Copyright (c) 未来飞马
- //
- // This Source Code Form is subject to the terms of the Mozilla Public
- // License, v. 2.0. If a copy of the MPL was not distributed with this
- // file, You can obtain one at https://mozilla.org/MPL/2.0/.
- //
- // Trademark Notice:
- // The MPL-2.0 license grants copyright permissions for source code only.
- // It does NOT grant any rights to use trademarks including "未来飞马",
- // "Harness Loop", "RSI", and associated slogan "让AI进化提前发生,让AI落地快人一步".
- // Any use of these trademarks requires separate written permission.
- /**
- * fmode-storage 大文件接收网关(bigfile-gateway)— 零依赖(Node ≥18)。
- *
- * 场景:主通道(如企微入站 20MB)挡住大文件时,生成一个自包含接收页,用户浏览器
- * **直连对象存储**(预签名 PUT)把大文件传上来,再由 collect 取回。绕开聊天通道。
- *
- * 链路(全部复用 uploader.mjs 的会话语义,密钥永不落盘、永不进页面):
- * 1. resolveSessionToken() ← uploader.mjs
- * 2. 项目注册/复用 → projectId ← 云函数 /fmode/project-register
- * 3. 项目级 STS(1h) ← /api/apig/deploy/huaweicloud
- * 4. 自己实现 SigV4 预签名(不使用云函数签名——它恒定 282s,不可用于上传页)
- * 5. 渲染 templates/gateway.html → <outDir>/index.html
- * 6. 用同一 STS 把 index.html 传回项目前缀 → 公网 URL https://app.fmode.cn/dev/<pid>/bigfile/
- * 7. 生成 collect.sh / mint.json(不含密钥)供取回
- *
- * ⚠️ 关键事实(实测 2026-10-13,见 SKILL.md 踩坑库):
- * - STS 真身有效期 1h(不是签名标称的 48h)→ 用户第二天传必失败,须重新 mint+部署。
- * - bucket 是 nova-cloud,region=cn-south-1(north-4 会 NoSuchBucket)。
- * - STS 策略只允许 prefix=dev/<pid>/ 的 GetObject/PutObject,**不允许 List** → 取回靠逐槽位探测,不靠列举。
- * - 不存在对象的公网 URL 在 CDN 上可能回落成 200 + text/html(占位页)→ 判「到位」必须排除 text/html。
- */
- import fs from 'fs';
- import os from 'os';
- import path from 'path';
- import crypto from 'crypto';
- import { fileURLToPath } from 'url';
- import {
- resolveSessionToken,
- fetchStsViaDeploy,
- } from './uploader.mjs';
- const FMODE_API_BASE = (process.env.FMODE_API_BASE || 'https://server.fmode.cn').replace(/\/$/, '');
- const FUNCTIONS_URL = `${FMODE_API_BASE}/api/functions`;
- const DEPLOY_STS_URL = `${FMODE_API_BASE}/api/apig/deploy/huaweicloud`;
- const PROJECT_REGISTER_PATH = '/fmode/project-register';
- const CDN_BASE = (process.env.FMODE_CDN_BASE || 'https://s3.fmode.cn').replace(/\/$/, '');
- const SITE_BASE = (process.env.FMODE_SITE_BASE || 'https://app.fmode.cn').replace(/\/$/, '');
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
- const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'gateway.html');
- const DEFAULT_SLOTS = 20;
- const DEFAULT_TTL_HOURS = 1; // STS 真身上限即 1h,标本一致(踩坑 #2)
- const DEFAULT_PROJECT_SLUG = 'bigfile-gateway';
- const DEFAULT_ENDPOINT = 'obs.cn-south-1.myhuaweicloud.com';
- // ============================================================================
- // SigV4 预签名(自实现;query 签名,payload=UNSIGNED-PAYLOAD)
- // ============================================================================
- function _sha256hex(s) { return crypto.createHash('sha256').update(s).digest('hex'); }
- function _hmac(key, data) { return crypto.createHmac('sha256', key).update(data).digest(); }
- /** RFC3986 转义(SigV4 要求:仅 A-Za-z0-9-_.~ 不转义) */
- function _rfc3986(str) {
- return encodeURIComponent(String(str)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
- }
- function _encodeKey(key) { return String(key).split('/').map(_rfc3986).join('/'); }
- /**
- * 华为云 OBS SigV4 预签名 URL。
- *
- * 只签 host(不签 content-type)——这样浏览器上传时可自由设置文件类型,
- * 且 x-obs-acl: public-read 走自定义头不会破坏签名(bucket 预检会回显请求头)。
- *
- * @param {object} o
- * @param {string} o.endpoint 如 obs.cn-south-1.myhuaweicloud.com
- * @param {string} o.region 如 cn-south-1(从 endpoint 推得)
- * @param {string} o.bucket
- * @param {string} o.key 对象键(不含前导 /)
- * @param {string} o.ak o.sk o.securityToken
- * @param {'PUT'|'GET'|'HEAD'} [o.method]
- * @param {number} [o.expires] 秒
- * @param {object} [o.query] 额外 query(如 list-type)
- * @returns {string} 预签名 URL
- */
- export function presignV4(o) {
- const now = o.now || new Date();
- const method = (o.method || 'PUT').toUpperCase();
- const host = `${o.bucket}.${o.endpoint}`;
- const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
- const dateStamp = amzDate.slice(0, 8);
- const scope = `${dateStamp}/${o.region}/s3/aws4_request`;
- const lower = { host };
- for (const [k, v] of Object.entries(o.headers || {})) lower[k.toLowerCase()] = String(v);
- const names = Object.keys(lower).sort();
- const canonicalHeaders = names.map((n) => `${n}:${lower[n].trim()}`).join('\n') + '\n';
- const signedHeaders = names.join(';');
- const q = {
- ...(o.query || {}),
- 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
- 'X-Amz-Credential': `${o.ak}/${scope}`,
- 'X-Amz-Date': amzDate,
- 'X-Amz-Expires': String(o.expires || DEFAULT_TTL_HOURS * 3600),
- 'X-Amz-SignedHeaders': signedHeaders,
- };
- if (o.securityToken) q['X-Amz-Security-Token'] = o.securityToken;
- const canonicalQuery = Object.keys(q).sort()
- .map((k) => `${_rfc3986(k)}=${_rfc3986(q[k])}`).join('&');
- const canonicalUri = '/' + _encodeKey(o.key || '');
- const payloadHash = 'UNSIGNED-PAYLOAD';
- const canonicalRequest = [method, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, payloadHash].join('\n');
- const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, _sha256hex(canonicalRequest)].join('\n');
- let k = _hmac(`AWS4${o.sk}`, dateStamp);
- k = _hmac(k, o.region); k = _hmac(k, 's3'); k = _hmac(k, 'aws4_request');
- const signature = crypto.createHmac('sha256', k).update(stringToSign).digest('hex');
- return `https://${host}${canonicalUri}?${canonicalQuery}&X-Amz-Signature=${signature}`;
- }
- /** 从 endpoint 推断 region:obs.cn-south-1.myhuaweicloud.com → cn-south-1 */
- export function regionOf(endpoint) {
- const m = String(endpoint || '').match(/obs\.([a-z0-9-]+)\./);
- return m ? m[1] : 'cn-south-1';
- }
- // ============================================================================
- // 项目注册 / STS
- // ============================================================================
- /** 注册或复用一个项目(云函数 /fmode/project-register,幂等:同名返回 reused:true)。 */
- export async function registerOrReuseProject(sessionToken, name) {
- if (!sessionToken || !name) return null;
- try {
- const res = await fetch(FUNCTIONS_URL, {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ token: sessionToken, path: PROJECT_REGISTER_PATH, params: { name } }),
- signal: AbortSignal.timeout(20000),
- });
- if (!res.ok) return null;
- const j = await res.json().catch(() => null);
- if (!j || !j.success || !j.projectId) return null;
- return { projectId: String(j.projectId), projectName: String(j.projectName || name), reused: !!j.reused };
- } catch { return null; }
- }
- /**
- * 解析本次网关使用的 projectId:
- * --project-id > 环境变量 > 注册/复用 <projectSlug>。
- * 为什么用项目而非个人空间:项目级 STS 是唯一能拿到 **1h 有效期签名** 的通道
- * (云函数签名恒定 282s),而个人空间无对应 STS 签发端点。
- */
- export async function resolveGatewayProject(sessionToken, { projectId, projectSlug } = {}) {
- if (projectId) return { projectId, projectName: projectSlug || null, reused: null, via: 'explicit' };
- const slug = projectSlug || process.env.FMODE_GATEWAY_PROJECT || DEFAULT_PROJECT_SLUG;
- const reg = await registerOrReuseProject(sessionToken, slug);
- if (reg) return { ...reg, via: 'cloudfunc:project-register' };
- return null;
- }
- /** 用 sessionToken + projectId 取项目级 STS(1h)。仅内存持有。 */
- export async function mintSts(sessionToken, projectId) {
- const sts = await fetchStsViaDeploy(sessionToken, projectId);
- if (!sts) return null;
- // 兜底:deploy 端点返回的 obsPath 已解析出 bucket/prefix;endpoint 固定 south-1。
- return {
- ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
- bucket: sts.bucket || 'nova-cloud',
- endpoint: sts.endpoint || DEFAULT_ENDPOINT,
- prefix: sts.prefix || `dev/${projectId}/`,
- projectId,
- expiresAt: sts.expiresAt || null,
- };
- }
- // ============================================================================
- // 槽位(slot)
- // ============================================================================
- /**
- * 生成 N 个槽位。每个槽位 = 一个独立对象键 + 一条独立预签名 PUT URL + 一条公网读 URL。
- *
- * 为什么槽位化:浏览器拿不到本地文件名,槽位号是唯一的服务端信号 —— 既是写入位置,
- * 也是「哪个位子有文件」的判据。原作者注释说「原始文件名靠取回阶段命名规则补回来」,
- * 本实现做了增强:文件名在**上传时**由页面显式写进槽位的 Content-Disposition 无关;
- * 页面把 file.name 一起记入本地清单,取回时按 manifest 还原 —— 更贴近「按文件名取回」。
- */
- export function buildSlots({ sts, count, slotPrefix = '', expires }) {
- const slots = [];
- const base = `${sts.prefix}${slotPrefix ? slotPrefix.replace(/^\/+|\/+$/g, '') + '/' : ''}`;
- for (let i = 0; i < count; i++) {
- const n = String(i).padStart(4, '0');
- const key = `${base}${n}.bin`;
- const url = presignV4({
- ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
- endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
- key, method: 'PUT', expires,
- });
- slots.push({
- n,
- key,
- contentType: 'application/octet-stream',
- url,
- publicUrl: `${SITE_BASE}/${key}`,
- });
- }
- return slots;
- }
- // ============================================================================
- // 页面渲染 + 部署
- // ============================================================================
- /** 渲染接收页(模板 + 内联 CONFIG,自包含单文件)。 */
- export function renderPage({ title, channelName, limitText, limitNote, publicBase, slots, ttlHours }) {
- const tpl = fs.readFileSync(TEMPLATE_PATH, 'utf8');
- const cfg = { publicBase, ttlHours, slots: slots.map((s) => ({ n: s.n, name: `${s.n}.bin`, url: s.url, publicUrl: s.publicUrl, contentType: s.contentType })) };
- const esc = (s) => String(s == null ? '' : s).replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' }[c]));
- // 配置 JSON 内联进 <script type="application/json">:需转义 </ 以免提前闭合脚本标签
- const cfgJson = JSON.stringify(cfg).replace(/<\//g, '<\\/');
- return tpl
- .replace(/__TITLE__/g, esc(title))
- .replace(/__CHANNEL_NAME__/g, esc(channelName))
- .replace(/__LIMIT_TEXT__/g, esc(limitText))
- .replace(/__LIMIT_NOTE__/g, esc(limitNote))
- .replace(/__TTL_HOURS__/g, String(ttlHours))
- .replace(/__CONFIG_JSON__/g, cfgJson);
- }
- /**
- * 部署页面:用同一 STS 把 index.html 直传项目前缀(而不是塞进个人空间或独立站点项目)。
- * 好处:仍在 STS 允许的 dev/<pid>/ 前缀内,key 与预期一致(规避站点 dist-deploy 重写坑)。
- * 落手即 public-read,公网 https://app.fmode.cn/dev/<pid>/bigfile/index.html
- */
- export async function deployPage(sts, html) {
- const key = `${sts.prefix}bigfile/index.html`;
- const url = presignV4({
- ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
- endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
- key, method: 'PUT', expires: 3600,
- });
- const res = await fetch(url, {
- method: 'PUT',
- headers: { 'Content-Type': 'text/html; charset=utf-8', 'x-obs-acl': 'public-read' },
- body: html,
- signal: AbortSignal.timeout(30000),
- });
- if (!res.ok) return { ok: false, status: res.status, body: (await res.text()).slice(0, 300) };
- return { ok: true, key, url: `${SITE_BASE}/${key}` };
- }
- // ============================================================================
- // collect(取回)
- // ============================================================================
- /**
- * 判「远端对象到位」:CDN 对不存在的路径可能回落 200 + text/html(占位页),
- * 因此仅凭 200 不足 —— 必须排除 text/html(踩坑 #1)。
- */
- export function looksLikeObject(status, contentType) {
- if (status !== 200) return false;
- const ct = String(contentType || '').toLowerCase();
- if (ct.includes('text/html')) return false;
- return true;
- }
- /**
- * 扫描槽位:对每个槽位的公网 URL 发 HEAD,判定是否已上传。
- * 不依赖 ListObjects(STS 策略不允许 List —— 实测 403)。
- */
- export async function scanSlots(manifest) {
- const found = [];
- for (const s of manifest.slots) {
- const url = `${SITE_BASE}/${s.key}`; // key 已含 dev/<pid>/ 前缀,勿再叠加 publicBase
- try {
- const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(15000) });
- const ct = res.headers.get('content-type');
- const len = Number(res.headers.get('content-length') || 0);
- if (looksLikeObject(res.status, ct)) {
- found.push({ n: s.n, key: s.key, bytes: len, contentType: ct, publicUrl: url });
- }
- } catch { /* 单槽位失败不阻断整体扫描 */ }
- }
- return found;
- }
- // ============================================================================
- // 主流程
- // ============================================================================
- /** 读取当前用户显示名(用于页面标题;缺失则回落到项目名/id)。非机密。 */
- export function resolveDisplayName() {
- const candidates = [
- path.join(os.homedir(), '.fmode', 'config', 'user.json'),
- path.join(os.homedir(), '.fmode', 'config.json'),
- '/opt/data/fmode-identity.json',
- ];
- for (const p of candidates) {
- try {
- if (!fs.existsSync(p)) continue;
- const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
- const n = j.displayName || j.username || j.user?.displayName || j.user?.username || j.userid || j.userId;
- if (n && String(n).trim()) return String(n).trim();
- } catch { /* next */ }
- }
- return null;
- }
- /**
- * mint:领 STS → 建槽位 → 渲染 → 部署 → 写 mint.json / collect.sh。
- * @returns {Promise<{ok:boolean, ...}>}
- */
- export async function gatewayMint(opts = {}) {
- const outDir = path.resolve(opts.outDir || process.cwd());
- const slotsCount = Number(opts.slots || DEFAULT_SLOTS);
- const ttlHours = Number(opts.hours || DEFAULT_TTL_HOURS);
- const channelName = opts.channel || '企微入站(用户 → 我)';
- const limitText = opts.limit || '20MB';
- const limitNote = opts.note || `${channelName} 是可协商不掉的上游硬限,文件在到达我这一侧之前就被拒收;改本地上限无效。`;
- const sessionToken = resolveSessionToken();
- if (!sessionToken) {
- return { ok: false, error: 'no_session_token', message: '未找到登录态 sessionToken(云函数/STS 通道均依赖它)' };
- }
- const proj = await resolveGatewayProject(sessionToken, { projectId: opts.projectId, projectSlug: opts.projectSlug });
- if (!proj) return { ok: false, error: 'project_unresolved', message: '项目注册/解析失败(云函数 /fmode/project-register 未成功)' };
- const sts = await mintSts(sessionToken, proj.projectId);
- if (!sts) {
- return { ok: false, error: 'sts_failed', message: `项目级 STS 签发失败(项目 ${proj.projectId} 无权限或 token 失效)` };
- }
- const slotPrefix = opts.slotPrefix != null ? opts.slotPrefix : `gateway/${Date.now()}`;
- const slots = buildSlots({ sts, count: slotsCount, slotPrefix, expires: Math.min(ttlHours * 3600, 3600) });
- const title = opts.title || `大文件接收网关 · ${resolveDisplayName() || proj.projectName || proj.projectId}`;
- const html = renderPage({
- title, channelName, limitText, limitNote,
- publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
- slots, ttlHours,
- });
- fs.mkdirSync(outDir, { recursive: true });
- const indexPath = path.join(outDir, 'index.html');
- fs.writeFileSync(indexPath, html, 'utf8');
- const manifest = {
- v: 1,
- kind: 'fmode-bigfile-gateway',
- createdAt: new Date().toISOString(),
- ttlHours,
- projectId: proj.projectId,
- projectName: proj.projectName || null,
- bucket: sts.bucket,
- endpoint: sts.endpoint,
- region: regionOf(sts.endpoint),
- prefix: sts.prefix,
- publicBase: `${SITE_BASE}/${sts.prefix.replace(/\/$/, '')}`,
- slotPrefix,
- slots: slots.map((s) => ({ n: s.n, key: s.key, name: `${s.n}.bin` })),
- indexPath,
- };
- // mint.json 只含非机密信息(projectId / key 列表)——签名 URL 有 1h 时效且随 STS 失效,不入盘。
- const manifestPath = path.join(outDir, 'mint.json');
- fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
- // 部署页面(可 --no-deploy 跳过,仅本地生成)
- let deployed = null;
- if (!opts.noDeploy) {
- deployed = await deployPage(sts, html);
- }
- // collect 包装脚本(取回用;内部再 mint 新鲜 STS,不落盘任何密钥)
- const collectSh = path.join(outDir, 'collect.sh');
- fs.writeFileSync(collectSh, renderCollectSh(__dirname), { mode: 0o755 });
- const report = {
- ok: true,
- pageUrl: deployed && deployed.ok ? deployed.url : null,
- pageKey: deployed && deployed.ok ? deployed.key : null,
- deployError: deployed && !deployed.ok ? `${deployed.status} ${deployed.body}` : null,
- slots: slotsCount,
- ttlHours,
- projectId: proj.projectId,
- projectName: proj.projectName || null,
- projectVia: proj.via,
- prefix: sts.prefix,
- publicBase: manifest.publicBase,
- stsExpiresAt: sts.expiresAt,
- indexPath,
- manifestPath,
- collectScript: collectSh,
- collectHint: `bash ${collectSh} --list # 只看哪些槽位已有文件\nbash ${collectSh} --out ~/inbox # 下载到 ~/inbox 并登记台账`,
- stsExpiresNote: `STS 真身有效期约 ${ttlHours}h;用户过时未传须重新 mint+部署(不可沿用旧页)`,
- };
- return report;
- }
- function renderCollectSh(scriptsDir) {
- return `#!/usr/bin/env bash
- # 大文件网关取回器 —— 扫描到位文件 → 下载 → 台账。
- # 不落盘任何密钥:每次运行重新 mint 项目级 STS(1h),只输出预签名 GET URL。
- # 用法:
- # bash collect.sh --list # 只列出已到位的槽位
- # bash collect.sh --out ~/inbox # 下载到 ~/inbox 并写 ledger.jsonl
- # bash collect.sh --manifest mint.json --out ./received
- set -euo pipefail
- DIR="$(cd "$(dirname "$0")" && pwd)"
- NODE_BIN="\${NODE_BIN:-node}"
- exec "$NODE_BIN" "${scriptsDir}/gateway.mjs" collect --manifest "$DIR/mint.json" "$@"
- `;
- }
- // ============================================================================
- // CLI
- // ============================================================================
- function parseArgs(rest) {
- const a = { _: [] };
- for (let i = 0; i < rest.length; i++) {
- const t = rest[i];
- 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++; } }
- else a._.push(t);
- }
- return a;
- }
- async function cmdMint(rest) {
- const a = parseArgs(rest);
- const outDir = a.out || a._[0] || path.join(process.cwd(), 'gateway-out');
- const report = await gatewayMint({
- outDir,
- slots: a.slots,
- hours: a.hours,
- title: a.title,
- channel: a.channel,
- limit: a.limit,
- note: a.note,
- slotPrefix: a['slot-prefix'],
- projectId: a['project-id'],
- projectSlug: a['project-slug'],
- noDeploy: !!a['no-deploy'],
- });
- if (!report.ok) { console.error(JSON.stringify(report, null, 2)); process.exit(2); }
- if (a.json) { console.log(JSON.stringify(report, null, 2)); return 0; }
- console.log('大文件接收网关已生成');
- console.log(' 页面 URL :', report.pageUrl || `(未部署:${report.deployError})`);
- console.log(' 本地文件 :', report.indexPath);
- console.log(' 槽位数 :', report.slots, ' 有效期约:', report.ttlHours + 'h');
- console.log(' 项目 :', report.projectId, `(${report.projectVia})`);
- console.log(' 前缀 :', report.prefix);
- console.log(' 取回 :', report.collectHint.replace(/\n/g, '\n '));
- console.log(' ⚠️ 发用户前先真传一个小文件实测(本工具不改这条铁律)。');
- return 0;
- }
- async function cmdCollect(rest) {
- const a = parseArgs(rest);
- const manifestPath = a.manifest || path.join(process.cwd(), 'mint.json');
- if (!fs.existsSync(manifestPath)) { console.error('找不到 manifest:', manifestPath); process.exit(2); }
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
- const outDir = a.out ? path.resolve(a.out) : null;
- console.log(`扫描槽位(${manifest.slots.length} 个)…`);
- const found = await scanSlots(manifest);
- if (found.length === 0) {
- console.log('没有检测到任何已到位的文件。');
- console.log('(若用户尚未上传,属正常;STS 过期需重新 mint+部署)');
- return 0;
- }
- console.log(`检测到 ${found.length} 个已到位文件:`);
- for (const f of found) console.log(` [${f.n}] ${f.key} ${f.bytes} B ${f.contentType}`);
- if (!outDir) { return 0; } // --list 行为
- const sessionToken = resolveSessionToken();
- const sts = await mintSts(sessionToken, manifest.projectId);
- if (!sts) { console.error('取回失败:无法 mint 新鲜 STS(sessionToken 失效或项目无权限)'); process.exit(2); }
- fs.mkdirSync(outDir, { recursive: true });
- const ledgerPath = path.join(outDir, 'ledger.jsonl');
- let ok = 0;
- for (const f of found) {
- const url = presignV4({
- ak: sts.ak, sk: sts.sk, securityToken: sts.securityToken,
- endpoint: sts.endpoint, region: regionOf(sts.endpoint), bucket: sts.bucket,
- key: f.key, method: 'GET', expires: 3600,
- });
- try {
- const res = await fetch(url, { signal: AbortSignal.timeout(120000) });
- if (!looksLikeObject(res.status, res.headers.get('content-type'))) { console.error(` 跳过 [${f.n}](非对象响应)`); continue; }
- const buf = Buffer.from(await res.arrayBuffer());
- const local = path.join(outDir, `${f.n}.bin`);
- fs.writeFileSync(local, buf);
- fs.appendFileSync(ledgerPath, JSON.stringify({ n: f.n, key: f.key, bytes: buf.length, contentType: f.contentType, localPath: local, at: new Date().toISOString() }) + '\n');
- console.log(` 下载 [${f.n}] → ${local} (${buf.length} B)`);
- ok++;
- } catch (e) { console.error(` 下载 [${f.n}] 失败: ${e.message}`); }
- }
- console.log(`完成:${ok}/${found.length} 已下载到 ${outDir};台账 ${ledgerPath}`);
- return 0;
- }
- export async function main(argv = process.argv.slice(2)) {
- const [cmd, ...rest] = argv;
- if (cmd === 'mint' || cmd === 'generate' || cmd === 'gen') return cmdMint(rest);
- if (cmd === 'collect' || cmd === 'fetch') return cmdCollect(rest);
- console.log(`用法(大文件接收网关):
- node gateway.mjs mint [--slots 20] [--hours 1] [--out DIR] [--title T]
- [--channel "企微入站"] [--limit 20MB] [--note "..."]
- [--project-id ID | --project-slug slug] [--slot-prefix P]
- [--no-deploy] [--json]
- node gateway.mjs collect --manifest mint.json [--out DIR] # 无 --out 即 --list`);
- return cmd === 'help' ? 0 : 2;
- }
- // 作为脚本直接运行时才执行(被 import 时不自动跑,避免干扰 uploader.mjs)
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
- main().then((c) => process.exit(c)).catch((e) => { console.error('未预期错误:', e.message); process.exit(1); });
- }
|