portal 上傳頁:POST /portal/data/upload(server-side 寫 Gitea)+前端拖放上傳
server:requirePortalUser 閘;filename 驗證(去路徑分隔、強制 .md、限 100 字);
server-side POST Gitea contents API 到 PORTAL_UPLOAD_REPO 的 docs/{filename}
——token 只在 server,前端零 repo/token 字串。409/422 → 誠實「同名文件已存在」。
新增三個 Bindings:PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN
(secret),任一缺 → 404「未啟用上傳」=Mira 零影響(功能不存在,現行為一字不變)。
/portal/session 補 upload_enabled 布林(server 判 bindings 齊全;顯示提示,真閘在路由層)。
前端:上傳 nav(sidenav+tabbar,一般用戶可見、未啟用時隱藏)+檔案選擇(.md/.txt
多選)+拖放區;讀檔轉 b64 逐檔 POST、逐檔狀態列;成功顯示「已收件,約 1 分鐘後
可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現」。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5
This commit is contained in:
@@ -23,7 +23,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import type { Bindings } from '../types';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible } from './portal';
|
||||
import { kbdbFetch, run, requirePortalUser, parseLibraries, portalTenant, hasGraphAccess, workflowsVisible, uploadEnabled } from './portal';
|
||||
import { graphBase } from './kbdb-proxy';
|
||||
import { executeWebhookGraph } from '../actions/webhook-handlers';
|
||||
|
||||
@@ -236,6 +236,77 @@ portalDataRouter.get('/portal/data/chat', (c) =>
|
||||
}),
|
||||
);
|
||||
|
||||
// ── 上傳(portal-demo-suite)────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 上傳檔名驗證(純函式,單測用 export):
|
||||
* - 去路徑分隔(/ \)只取最後一段(擋 ../ 穿越)、去控制字元;
|
||||
* - 空名/以 . 開頭(隱藏檔/./..)→ 無效(null);
|
||||
* - 強制 .md 結尾(.txt 改副檔名、其餘直接補 .md——上傳面收的是知識文件,一律當 markdown 收件);
|
||||
* - 最終長度限 100(含副檔名),超過 → 無效。
|
||||
*/
|
||||
export function sanitizeUploadFilename(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
let name = (raw.split(/[/\\]/).pop() ?? '').trim();
|
||||
// eslint-disable-next-line no-control-regex
|
||||
name = name.replace(/[\u0000-\u001f\u007f]/g, '');
|
||||
if (!name || name.startsWith('.')) return null;
|
||||
if (/\.txt$/i.test(name)) name = name.replace(/\.txt$/i, '.md');
|
||||
if (!/\.md$/i.test(name)) name = `${name}.md`;
|
||||
if (name.length > 100) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
// base64 內容上限(收 .md/.txt 知識文件,2 MiB 原文 ≈ 2.7 MB base64 已綽綽有餘;防拿上傳面塞大檔)
|
||||
const MAX_UPLOAD_B64_CHARS = 3 * 1024 * 1024;
|
||||
|
||||
// POST /portal/data/upload — body {filename, content_b64}(portal-demo-suite)。
|
||||
// requirePortalUser 閘;server-side POST Gitea contents API 寫進 PORTAL_UPLOAD_REPO 的
|
||||
// docs/{filename}——token 只在 server 側,前端永遠拿不到(同 kbdb-proxy token 慣例)。
|
||||
// 未設 upload bindings(三者任一缺)→ 404「未啟用上傳」(Mira 零影響:功能不存在)。
|
||||
portalDataRouter.post('/portal/data/upload', (c) =>
|
||||
run(c, async () => {
|
||||
const auth = await requirePortalUser(c);
|
||||
if (!auth.ok) return auth.res;
|
||||
if (!uploadEnabled(c.env)) return c.json({ error: '此實例未啟用上傳' }, 404);
|
||||
|
||||
const body = await c.req.json().catch(() => null) as { filename?: unknown; content_b64?: unknown } | null;
|
||||
const filename = sanitizeUploadFilename(body?.filename);
|
||||
if (!filename) return c.json({ error: 'filename 無效(不可含路徑、不可空、長度限 100)' }, 400);
|
||||
const contentB64 = body?.content_b64;
|
||||
if (typeof contentB64 !== 'string' || !contentB64) return c.json({ error: 'content_b64 必填' }, 400);
|
||||
if (contentB64.length > MAX_UPLOAD_B64_CHARS) return c.json({ error: '檔案過大(上限約 2 MB)' }, 413);
|
||||
|
||||
const base = (c.env.PORTAL_UPLOAD_GITEA ?? '').replace(/\/$/, '');
|
||||
const repo = c.env.PORTAL_UPLOAD_REPO ?? '';
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${base}/api/v1/repos/${repo}/contents/docs/${encodeURIComponent(filename)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${c.env.PORTAL_UPLOAD_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: contentB64,
|
||||
// commit 訊息帶上傳者(display_name 非機密),收件溯源用;不進任何內容
|
||||
message: `portal 上傳:docs/${filename}(${auth.user.values.display_name ?? 'portal user'})`,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
return c.json({ error: `知識庫收件服務不可達:${e instanceof Error ? e.message : String(e)}` }, 502);
|
||||
}
|
||||
if (res.status === 409 || res.status === 422) {
|
||||
// Gitea 同 path 已存在(版本差異回 409 或 422)→ 統一誠實回 409
|
||||
return c.json({ error: '同名文件已存在,請改檔名後重傳' }, 409);
|
||||
}
|
||||
if (!res.ok) {
|
||||
return c.json({ error: `知識庫收件失敗(HTTP ${res.status})` }, 502);
|
||||
}
|
||||
return c.json({ success: true, filename, path: `docs/${filename}` });
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /portal/data/workflows — 工作流顯示(D-8):唯讀 list+每條的最近一次執行,**不開 trigger**
|
||||
//(trigger 是 owner/console 的事;回應也不含 webhook_url,不給可打的把手)。
|
||||
// 可見性:PORTAL_SHOW_WORKFLOWS=admin(預設,role 閘 403)/ all / off(整頁不存在 → 404)。
|
||||
|
||||
Reference in New Issue
Block a user