diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 6dd5ad3..6c9aee7 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -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)。 diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts index 26a7ae6..c2af9a9 100644 --- a/cypher-executor/src/routes/portal-ui.ts +++ b/cypher-executor/src/routes/portal-ui.ts @@ -215,6 +215,7 @@ function renderPortalHtml(brand: string): string {
+ +