|
|
@@ -0,0 +1,523 @@
|
|
|
+// 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); });
|
|
|
+}
|