// 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 → /index.html * 6. 用同一 STS 把 index.html 传回项目前缀 → 公网 URL https://app.fmode.cn/dev//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// 的 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 > 环境变量 > 注册/复用 。 * 为什么用项目而非个人空间:项目级 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 内联进