--- name: fmode-storage description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储并拿到公开分享链接,本地零长期占用。**默认通道免配置密钥**:只要有登录 sessionToken,平台云函数就签发上传地址(对象键 user/<你的id>/...,公网 https://s3.fmode.cn/)。适用:(1) 报告/课件发布即分享 (2) 图片/音视频素材托管 (3) 批量上传+公开读 (4) 需要公开 URL 供转发或嵌入。" description_en: "Upload binary files (images/audio/video/HTML reports) to object storage and get public share URLs, keeping local disk clean. The default channel needs no credentials: with a login sessionToken the platform cloud function issues an upload URL (object key user//..., public https://s3.fmode.cn/). Use for publishing reports/courseware, hosting media assets, batch upload with public-read, or any scenario needing public URLs." schema_version: 1.2.0 level: system category: publishing keywords: - storage - obs - s3 - upload - cdn - share-link - bigfile - gateway - presigned - sigv4 version: 1.2.0 author: Yuyang001 (FmodeAgent) license: MPL-2.0 copyright: "Copyright (c) 2026 未来飞马 Fmode" tags: [未来飞马, 智能体技能, 超级技能, 服务级, 数据管理, FmodeAgent, Hermes Agent, FmodeCode, Claude Code, storage, obs, s3, cdn, upload] --- # Fmode Storage — 对象存储与公开分享 ## Overview 把本地二进制文件交给对象存储,生成公开 URL 供分享、转发或嵌入。 设计原则:**本地零长期占用**;**公开即所得**;**凭据零入库**——密钥永不落盘、永不进日志、永不进对话;**诚实失败**——通道全失败时打印初始化向导并 `exit 2`,绝不伪装成功。 ## 首选用法(免配置,0 步) **不需要申请 AK/SK、不需要 init**。只要容器里有登录态 `sessionToken`(数字生命容器默认就有),直接: ```bash node /scripts/uploader.mjs put ./report.html --key report/202609/report.html # → { "ok": true, "key": "user/<你的id>/report/202609/report.html", # "url": "https://s3.fmode.cn/user/<你的id>/report/202609/report.html", # "via": "level0:cloudfunc(AlP56LCKFm)" } ``` 自检(验证这条通道是否可用): ```bash node /scripts/uploader.mjs test # → { "ok": true, "via": "level0:cloudfunc(...)", "message": "云函数免密钥通道自检成功(本机无需任何 AK/SK)" } ``` > **`--key` 给相对路径即可**,脚本/云函数会强制挂到 `user/<你的id>/` 前缀下——写不进别人的前缀。 > 省略 `--key` 则自动生成 `user/<你的id>/report//-<文件名>`。 > 上传即 `public-read`,**不需要**再调 `setacl`。 ## 项目级 Storage 正确姿势(2026-10-12 实测修复) **不要用 uploader put 走个人级当"项目级"!** uploader put 的首选通道是 level0 cloudfunc(个人空间 user//)。 项目级 = nova-cloud 桶 `dev//` 前缀,公网 https://app.fmode.cn/dev//。正确流程: 1. 项目未注册先注册:`POST server.fmode.cn/api/functions {token, path:'/fmode/project-register', params:{name:''}}` → 返回 projectId(objectId,如 GvsWkfZb36) 2. 签发项目级 STS:`POST server.fmode.cn/api/apig/deploy/huaweicloud {token, projectId}` → 200 返回 accessKey/secretKey/securityToken + obsPath `obs://nova-cloud/dev//` 3. obsutil ini(无[default]段头,endpoint=obs.cn-south-1配合nova-cloud 桶): ``` endpoint=obs.cn-south-1.myhuaweicloud.com ak=... sk=... token=... ``` 4. 上传:`obsutil cp obs://nova-cloud/dev// -f -acl=public-read -config=` (**必须 -config= 指定 ini,不要 obsutil config 全局污染**;不要用 obsutil ls 测试权限——直接 cp 才准,ls 可能因 STS policy 范围报 403) 5. CDN 刷新 app.fmode.cn 域名组 脚本:`/opt/data/scripts/upload-project.sh ` 认证区身份:sessionToken 用 ~/.fmode/config/user.json(真实用户,userId=liuyuyang),不是 ~/.fmode/config.json。 坑:①赖着 uploader put(level0 个人空间)当"项目级"= 错误;②obsutil 用 `obsutil config -c` 写临时 ini 会污染且报 not well-formed,用 `-config=` 参数指向自立 ini;③deploy STS endpoint 是 obs.cn-south-1(nova-cloud 桶在 south-1,north-4 会 NoSuchBucket/签名不匹配 403);④**`resolveStorageProjectId()` 常命中一个不属于当前用户的陈旧 projectId**(本机 `~/.fmode/config.json` = `GvsWkfZb36`),此时 deploy STS 返回 `{code:403,"没有权限"}`——用 `gateway` 子命令会自动注册/复用自己的项目(`bigfile-gateway`),或显式 `--project-id` 传一个本人有权限的 objectId。 ``` ┌─ 第0级【首选·免密钥】sessionToken → 云函数 fmodeagent-upload-url 签发预签名 PUT │ → AK/SK 由云函数保管,容器只拿 5 分钟有效的上传地址。无需任何本地配置。 │ 第1级 环境变量 OBS_AK/OBS_SK(/OBS_ENDPOINT/OBS_BUCKET) │ 第2级 obsutil config(OBSUTIL_CONFIG 或 ~/.obsutilconfig) │ 第3级 sessionToken + storageProjectId → POST /api/apig/deploy/huaweicloud │ (项目级 STS,作用域 obs://nova-cloud/dev//,非个人空间) │ 第4级 项目级 ./.fmode/config.json(obsBucket/obsEndpoint/cdnDomain) │ 全失败 → 打印初始化向导并 exit 2 └──────────────────────────────────────────────────────────────────────────┘ ``` **为什么第 0 级是首选**:第 1/2 级要每台容器各自配 OBS 密钥、第 3 级要 `storageProjectId` 且作用域是项目目录、第 4 级只有 bucket 名没有凭据——都无法做到"开箱即用"。第 0 级只依赖登录态,是唯一零配置通道。 第 1–4 级作为**兜底**保留(例如需要写 `obs://www-fmode.cn/` 之类平台桶,而不是个人空间)。 ## Content-Type 与 MIME(v1.2.0 起) `put` 会**按扩展名自动推断**并透传 `Content-Type`,HTML/CSS/JS/SVG/JSON 上传后可直接在浏览器渲染 (不再触发下载)。需要强制指定时用 `--mime`: ```bash node /scripts/uploader.mjs put ./data.bin --key x/data.bin --mime application/pdf ``` > ⚠️ **`--key` 只写相对路径**(如 `about-me/index.html`)。云函数会自动补 `user/<你的id>/`; > 若 key 里已写了 `user//`,v1.2.0 会自动剥离并告警,但正确写法是不要带。 > ⚠️ **覆盖同名 key 后可能"改了没生效"**:CDN 缓存 TTL 为 30 天,且缓存的是**整个响应(含 Content-Type 头)**。 > 建议换新 key 路径发布(如 `about-me-v2/index.html`)。 ## 大文件网关(bigfile-gateway)— 通道超限时生成接收页 **何时用**:主通道被**不可协商的大小上限**挡住时(典型:企微入站 20MB,用户发大文件报「超过 100M 无法发送」, 文件从未到达智能体)。此时**不要再走聊天通道**——生成一个接收页,用户浏览器**直连对象存储**上传, 再由 collect 取回。**平时不生成**,只在超限时触发。 > 为什么必须对象存储中转:容器跑在 NAT 内网,**没有公网入口**,不能自建接收服务(踩坑 #4)。 > 两段式「直传 + 取回」是唯一可行解。 ### 命令 ```bash # 生成 + 部署接收页(默认 20 槽位、1 小时有效),输出页面 URL node /scripts/uploader.mjs gateway # 等价:node /scripts/gateway.mjs mint # 常见参数 node scripts/gateway.mjs mint ./gw --slots 100 --hours 1 \ --title "大文件接收网关 · 刘雨飏" \ --channel "企微入站" --limit 20MB \ --note "企微入站在 20MB 处直接拒收,文件在到达前就已不存在" # --project-slug 复用/注册的项目名(默认 bigfile-gateway,幂等) # --project-id 直接指定已有项目 objectId # --slot-prefix

槽位键前缀(默认 gateway/<时间戳>) # --no-deploy 只本地生成,不部署 # --json 机器可读输出 # 取回(扫描到位文件 → 下载 → 台账) node scripts/gateway.mjs collect --manifest /mint.json # 只列 node scripts/gateway.mjs collect --manifest /mint.json --out ~/inbox # 下载 # 生成的 /collect.sh 已封装以上命令:bash collect.sh [--list|--out DIR] ``` ### 流程(5 步) 1. `resolveSessionToken()` 取登录态(复用 uploader 的会话语义) 2. 云函数 `/fmode/project-register` 注册/复用项目 → `projectId`(幂等) 3. `POST /api/apig/deploy/huaweicloud {token, projectId}` → **项目级 STS(真身 1h)** 4. **自己实现 SigV4** 为 N 个槽位各签一条预签名 PUT(host-only 签名,`UNSIGNED-PAYLOAD`), 渲染 `templates/gateway.html` → `/index.html`,再用同一 STS 把页面传回 `dev//bigfile/index.html` 5. 输出**页面 URL**(`https://app.fmode.cn/dev//bigfile/index.html`)+ `mint.json` + `collect.sh` 用户传完 → `collect` 逐槽位 HEAD 探测 → 下载到本地 + 写 `ledger.jsonl`(只记 key/大小/路径)。 ### 页面特性 单文件自包含、中文、移动端可用:拖拽/点选多文件、逐文件进度条、完成显示「上传成功 ✓」+ 查看链接; **不含任何 AK/SK/token**(只内联当次预签名 URL);文件只写各自槽位(`gateway//0000.bin`…),互不影响。 ### ⚠️ 踩坑库(实测 2026-10-13,必守) | # | 坑 / 现象 | 根因 | 本实现的做法 | |---|---|---|---| | 1 | 不存在对象的公网 URL 被 CDN 回落成 **200 + text/html**(占位页),全被误判「有文件」 | 只看状态码判「到位」 | `looksLikeObject()`:**必须排除 `content-type: text/html`**;实测该桶对缺失 key 返回 404,但仍严格按此判 | | 2 | 签名标称 48h,用户**第二天传全失败** | 签名带 `X-Amz-Security-Token`,**STS 真身只 1h**;可用窗口 = min(签名, 1h) | 页面 `--hours` 默认 1、封顶 1h;用户过时须 **重新 mint + 重新部署**,不可沿用旧页 | | 3 | 读 `.obsutilconfig` 直接失败 | 里面是**占位符凭据**(`http://your-endpoint` + 假 AK/SK) | 不读它;凭据走项目 STS,签名自实现 | | 4 | 想「起 http 服务让用户传」外部连不上 | 容器在 NAT 内网、无公网入口 | 放弃自建接收端,用对象存储中转 | | 5 | 用云函数签的 URL 做上传页,用户点开已失效 | 云函数签名 `expires/ttl/expiresIn` 全被忽略,**恒定 282s** | **不用**云函数签名;只用 `/api/functions` 注册项目拿到项目 STS(1h),签名自实现 | | 6 | 页面发出后用户传不上去,来回沟通成本极高 | 生成→部署间有多道环节(签名/CORS/key 前缀/CDN) | **生成后必须先真传一个小文件实测**再发用户(本工具不改这条铁律) | | 7 | 页面放进站点项目后 key 与预期不符、取回扫不到 | 站点 `dist-deploy` 重写规则给绝对路径加项目前缀 | 页面**不塞独立站点项目**,直接用同一 STS 写入 `dev//bigfile/`,key 与预期一致 | | 8 | bucket `NoSuchBucket` | **nova-cloud 桶在 cn-south-1**(north-4 无此桶) | endpoint 固定 `obs.cn-south-1.myhuaweicloud.com`,region `cn-south-1` | | 9 | 逐槽位扫描想用 `ListObjects` | **STS 策略不允许 List**(实测 403,只允许 prefix 内 GetObject/PutObject) | 取回靠**逐槽位 HEAD 探测**(`app.fmode.cn/`),不依赖列举 | ### 实测(2026-10-13,本机) - 页面:`https://app.fmode.cn/dev/OtEAl5m4JP/bigfile/index.html`(项目 `bigfile-gateway`→`OtEAl5m4JP`) - 真传:浏览器同一路径的预签名 PUT → slot `0000`/`0002` 上传 **200** → 公网 GET 回读 **200** 内容一致(含中文) - 取回:`collect --list` 命中 2/2;`collect --out` 下载 2 文件 + `ledger.jsonl` 全部成功 - SigV4 预签名 PUT/GET、CORS 预检(回显 `content-type,x-obs-acl`)均为 200 ### 安全边界 预签名 URL **只对单个 key 有 PUT 权限**(不能读/列/删其他对象)——链接即使公开,别人也只能往那一个空槽位写。 页面 URL 本身公开:敏感资料用 `--hours 1` 短效并告知用户「用完即焚」。 ## 何时用 - 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出 - 图片/音频/视频素材需要稳定外链 → `put` - 本地磁盘被大文件占满 → `put` 完成后可删本地原件 - **用户发来的文件被通道上限挡住(如企微 20MB)→ `gateway` 生成接收页 + `collect` 取回** ## 命令 ```bash node uploader.mjs test # 自检(优先走第0级) node uploader.mjs put --key <相对key> # 上传并返回公开 URL node uploader.mjs put # 省略 --key:自动命名 node uploader.mjs put --key k --ns report node uploader.mjs setacl --key # 第0级下上传即 public-read,无需调用 node uploader.mjs config # 诊断(输出绝不含密钥本体) node uploader.mjs init --ak .. --sk .. # 仅第1/2级兜底场景需要 ``` ## 需要完整 SDK 时 ```js import { putViaCloudFunction } from './scripts/uploader.mjs'; const r = await putViaCloudFunction('/path/report.html', 'report/202609/report.html', 'report'); // r = { ok, key, url, bytes, via } ``` ## 详细文档 见仓库根 `README.md`(多工具安装:FmodeCode / Claude Code / Codex / Gemini CLI / WorkBuddy / FmodeAgent / Hermes Agent + 完整 changelog)。 ## 铁律 - ⛔ 绝不把 AK/SK、sessionToken、STS 或任何密钥写进仓库、日志、对话 - ⛔ 绝不尝试从 git 仓库/代码/文档/聊天记录中寻找他人或公司凭证 - ✅ 对象只能写自己 `user/<自己的id>/` 前缀(云函数在服务端强制)