|
|
@@ -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 级兜底
|