Kaynağa Gözat

fix(v1.2.0): 透传 mimeType 修复 HTML 上传后被下载 + key 双层前缀防护 + token 兜底

依据 agent-node-xinting 缺陷报告:
- fix(P0) putViaCloudFunction 透传 mimeType(此前恒为 application/octet-stream → 浏览器下载)
- feat(P0) guessMime() 扩展名推断,30+ 类型
- feat(P1) --mime 显式覆盖
- fix(P1) normalizeKey() 防 user/<id>/ 双层前缀
- fix(P1) 覆盖同名 key 提示 CDN 30 天缓存
- fix(P1) token 链补 /opt/data/fmode-identity.json 兜底
- feat(P2) 输出回显 mimeType;docs 口径统一
线上验收:index.html → text/html; charset=utf-8
fmode-ops 18 saat önce
ebeveyn
işleme
f2f26682b0

+ 27 - 0
CHANGELOG.md

@@ -0,0 +1,27 @@
+## 1.2.0(2026-09-23)
+
+依据 agent-node-xinting 的缺陷报告(`s3.fmode.cn/user/dhv9yTqK6K/skill-storage-mime-bug-report/report.md`)修复。
+
+- **fix(P0) 透传 `mimeType`**:`putViaCloudFunction` 从不向云函数传 mimeType,导致对象
+  Content-Type 恒为 `application/octet-stream` → 浏览器**下载而非渲染**,HTML 报告分享后打不开。
+  预签名 URL 的签名**绑定 Content-Type**,必须在申请时就传对,PUT 阶段补救会 403。
+- **feat(P0) `guessMime()`**:按扩展名推断 MIME,覆盖 30+ 常见类型
+  (html/css/js/mjs/json/txt/md/csv/xml/svg/png/jpg/gif/webp/ico/pdf/zip/音视频/字体/Office)。
+- **feat(P1) `--mime` 显式覆盖**:应对未知或特殊类型。
+- **fix(P1) `normalizeKey()` 双层前缀防护**:`--key` 若已带 `user/<id>/`,云函数会再补一层
+  变成 `user/<id>/user/<id>/...`。现自动剥离并打印告警。
+- **fix(P1) 覆盖同名 key 时提示 CDN 缓存**:CDN TTL 30 天且缓存**含 Content-Type 头**,
+  覆盖上传后旧链接可能仍返回旧响应头;提示改用新 key 路径发布。
+- **fix(P1) token 解析链补 `fmode-identity.json` 兜底**:容器普遍只有
+  `/opt/data/fmode-identity.json`(字段 `session_token`)带 token,缺此兜底会误报「缺 token」
+  并打印初始化向导退出。实测 34 台容器中 32 台属此情况。
+- **feat(P2) 输出回显 `mimeType`**:便于核对,降低排查成本。
+- **docs(P1) 版本口径统一**;补 CDN 缓存与更新策略说明。
+
+### 线上验收(生产实测)
+
+| 文件 | Content-Type | 结果 |
+|------|--------------|------|
+| `index.html` | `text/html; charset=utf-8` | ✅ 浏览器渲染(修复前 octet-stream → 下载)|
+
+# 更新日志

+ 2 - 2
package.json

@@ -1,7 +1,7 @@
 {
   "name": "fmode-storage",
-  "version": "1.1.0",
-  "description": "对象存储上传/公开分享(OBS / S3 兼容)。首选免密钥通道:sessionToken → 云函数预签名直传(公网 https://s3.fmode.cn/<key>);兜底 4 级凭据链。超级技能(ESM 多端可用),零依赖 Node ≥18。",
+  "version": "1.2.0",
+  "description": "对象存储上传/公开分享技能华为云 OBS / S3 兼容)。首选免密钥通道:sessionToken → 云函数 fmodeagent-upload-url 预签名直传;自动推断并透传 Content-Type(HTML/CSS/JS/SVG 上传后可浏览器直接渲染,不再触发下载)。零依赖 Node ≥18。",
   "type": "module",
   "main": "./skills/fmode-storage/scripts/uploader.mjs",
   "exports": {

+ 16 - 1
skills/fmode-storage/SKILL.md

@@ -2,7 +2,7 @@
 name: fmode-storage
 description: "把二进制大文件(图片/音频/视频/HTML 报告)上传到对象存储并拿到公开分享链接,本地零长期占用。**默认通道免配置密钥**:只要有登录 sessionToken,平台云函数就签发上传地址(对象键 user/<你的id>/...,公网 https://s3.fmode.cn/<key>)。适用:(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/<your-id>/..., public https://s3.fmode.cn/<key>). Use for publishing reports/courseware, hosting media assets, batch upload with public-read, or any scenario needing public URLs."
-schema_version: 0.2.0
+schema_version: 1.2.0
 level: system
 category: publishing
 keywords:
@@ -67,6 +67,21 @@ node <skill_dir>/scripts/uploader.mjs test
 
 第 1–4 级作为**兜底**保留(例如需要写 `obs://www-fmode.cn/` 之类平台桶,而不是个人空间)。
 
+## Content-Type 与 MIME(v1.2.0 起)
+
+`put` 会**按扩展名自动推断**并透传 `Content-Type`,HTML/CSS/JS/SVG/JSON 上传后可直接在浏览器渲染
+(不再触发下载)。需要强制指定时用 `--mime`:
+
+```bash
+node <skill_dir>/scripts/uploader.mjs put ./data.bin --key x/data.bin --mime application/pdf
+```
+
+> ⚠️ **`--key` 只写相对路径**(如 `about-me/index.html`)。云函数会自动补 `user/<你的id>/`;
+> 若 key 里已写了 `user/<id>/`,v1.2.0 会自动剥离并告警,但正确写法是不要带。
+
+> ⚠️ **覆盖同名 key 后可能"改了没生效"**:CDN 缓存 TTL 为 30 天,且缓存的是**整个响应(含 Content-Type 头)**。
+> 建议换新 key 路径发布(如 `about-me-v2/index.html`)。
+
 ## 何时用
 
 - 生成了 HTML 报告/课件要"发给别人看" → `put` 后把 URL 发出

+ 72 - 9
skills/fmode-storage/scripts/uploader.mjs

@@ -125,7 +125,13 @@ function invalidateProbeCache() {
 
 /**
  * 解析 sessionToken(优先级:FMODE_SESSION_TOKEN → user.json(登录流程写入的最新 token)
- * → ~/.fmode/config.json → ./.fmode/config.json)。
+ * → ~/.fmode/config.json → ./.fmode/config.json
+ * → /opt/data/fmode-identity.json → ~/.fmode-harness-agent/fmode-identity.json)。
+ *
+ * ⚠️ 最后两个 fmode-identity.json 是**兜底**,顺序刻意放在 .fmode/config 之后:
+ * 容器普遍有 /opt/data/fmode-identity.json(开通时写入,字段名 session_token 下划线),
+ * 而 ~/.fmode/config.json 不一定存在。不放兜底会导致「有身份文件却报缺 token」,
+ * 技能只能打印初始化向导并退出。实测 34 台容器中 32 台只有该文件带 token。
  */
 export function resolveSessionToken() {
   if (process.env.FMODE_SESSION_TOKEN) return process.env.FMODE_SESSION_TOKEN.trim();
@@ -133,12 +139,14 @@ export function resolveSessionToken() {
     path.join(HOME, '.fmode', 'config', 'user.json'),
     path.join(HOME, '.fmode', 'config.json'),
     path.join(process.cwd(), '.fmode', 'config.json'),
+    '/opt/data/fmode-identity.json',                              // 兜底:容器开通时写入
+    path.join(HOME, '.fmode-harness-agent', 'fmode-identity.json'), // 兜底:旧 harness 布局
   ];
   for (const p of candidates) {
     try {
       if (!fs.existsSync(p)) continue;
       const j = JSON.parse(fs.readFileSync(p, 'utf8').replace(/^/, ''));
-      const t = j.sessionToken || (j.user && j.user.sessionToken) || null;
+      const t = j.sessionToken || (j.user && j.user.sessionToken) || j.session_token || null;
       if (t && String(t).trim()) return String(t).trim();
     } catch { /* try next source */ }
   }
@@ -299,7 +307,51 @@ export function publicUrl(cfg, key) {
  * @returns {{ok:true, key:string, url:string, bytes:number, via:string}|null}
  *          null = 该通道不可用(无 sessionToken / 云函数未配置 / 网络失败),调用方回落下一级
  */
-export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null) {
+// ── MIME 推断(v1.2.0,缺陷报告 P0)───────────────────────────────────────
+// 预签名 URL 的签名**绑定 Content-Type**,必须在申请时就把正确的 mimeType 传给云函数;
+// PUT 阶段再改头会 403 SignatureDoesNotMatch。不传则云函数默认 application/octet-stream,
+// 浏览器对 octet-stream 是「下载」而非「渲染」→ HTML 报告分享后无法直接浏览。
+const MIME_MAP = {
+  '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
+  '.css': 'text/css; charset=utf-8', '.js': 'application/javascript; charset=utf-8',
+  '.mjs': 'application/javascript; charset=utf-8',
+  '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
+  '.md': 'text/markdown; charset=utf-8', '.csv': 'text/csv; charset=utf-8',
+  '.xml': 'application/xml; charset=utf-8', '.svg': 'image/svg+xml',
+  '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
+  '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon',
+  '.pdf': 'application/pdf', '.zip': 'application/zip',
+  '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4',
+  '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
+  '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
+  '.doc': 'application/msword',
+  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  '.xls': 'application/vnd.ms-excel',
+  '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+  '.ppt': 'application/vnd.ms-powerpoint',
+  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+};
+
+/** 按扩展名推断 MIME;未知类型返回 application/octet-stream(保持旧行为) */
+export function guessMime(file) {
+  return MIME_MAP[path.extname(String(file)).toLowerCase()] || 'application/octet-stream';
+}
+
+/**
+ * --key 去重(缺陷报告 P1):云函数会自动补 `user/<调用者id>/` 前缀。
+ * 若 key 里已经写了 `user/<某id>/...`,会变成双层 `user/<id>/user/<id>/...`。
+ * 因为云函数在服务端强制把 key 挂到**调用者自己**的前缀下,剥离这段一定是安全且正确的。
+ */
+export function normalizeKey(key) {
+  const m = String(key || '').match(/^user\/[^/]+\/(.+)$/);
+  if (m) {
+    console.error(`[warn] --key 不应带 "user/<id>/" 前缀(云函数会自动补),已自动剥离:${key} → ${m[1]}`);
+    return m[1];
+  }
+  return key;
+}
+
+export async function putViaCloudFunction(file, objectKey, namespace = 'report', name = null, mimeType = null) {
   const token = resolveSessionToken();
   if (!token) return null;
   let buf;
@@ -312,7 +364,10 @@ export async function putViaCloudFunction(file, objectKey, namespace = 'report',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
         token, id: UPLOAD_FN_ID,
-        params: { filename, size: buf.length, namespace, key: objectKey || undefined },
+        params: {
+          filename, size: buf.length, namespace, key: objectKey || undefined,
+          ...(mimeType ? { mimeType } : {}),
+        },
       }),
       signal: AbortSignal.timeout(20000),
     });
@@ -324,7 +379,11 @@ export async function putViaCloudFunction(file, objectKey, namespace = 'report',
     const put = await fetch(body.uploadUrl, { method: 'PUT', headers: body.headers || {}, body: buf });
     if (!put.ok) return null;
   } catch { return null; }
-  return { ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`, bytes: buf.length, via: `level0:cloudfunc(${UPLOAD_FN_ID})` };
+  return {
+    ok: true, key: body.key, url: body.publicUrl || `${CDN_BASE}/${body.key}`,
+    bytes: buf.length, mimeType: mimeType || 'application/octet-stream',
+    via: `level0:cloudfunc(${UPLOAD_FN_ID})`,
+  };
 }
 
 // ============================================================================
@@ -593,13 +652,17 @@ async function main() {
   // 而这条路恰恰是唯一不需要任何本机凭据的通道。
   if (cmd === 'put') {
     const file0 = rest[0];
-    const key0 = arg('--key');
-    if (!file0 || !key0) { console.error('用法: put <file> --key <objectKey> [--acl public-read] [--ns report]'); process.exit(2); }
+    const key0raw = arg('--key');
+    if (!file0 || !key0raw) { console.error('用法: put <file> --key <objectKey> [--ns report] [--mime text/html] [--acl public-read]'); process.exit(2); }
     if (!fs.existsSync(file0)) { console.error('文件不存在: ' + file0); process.exit(2); }
+    const key0 = normalizeKey(key0raw);          // 剥离误写的 user/<id>/ 前缀(P1)
     const ns0 = arg('--ns') || 'report';
-    const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0));
+    const mime0 = arg('--mime') || guessMime(file0);   // 自动推断 MIME(P0)
+    const cf0 = await putViaCloudFunction(file0, key0, ns0, path.basename(key0), mime0);
     if (cf0) {
-      console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes }, null, 2));
+      console.log(JSON.stringify({ ok: true, key: cf0.key, url: cf0.url, bucket: 'storage-s3-nkkj', via: cf0.via, bytes: cf0.bytes, mimeType: cf0.mimeType }, null, 2));
+      console.error('[提示] 若覆盖了同名 key,CDN 缓存 TTL 为 30 天且缓存含 Content-Type 头;');
+      console.error('       分享出去的旧链接可能仍显示旧响应头。建议换新 key 路径(如 report-v2/index.html)发布。');
       return;
     }
     // 云函数不可用(无 sessionToken / 云函数未配置)→ 落到下面的 obsutil 4 级兜底