diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 7093885..0a628b4 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -23,11 +23,56 @@ 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'; export const portalDataRouter = new Hono<{ Bindings: Bindings }>(); +// ── tenant workflow in-process 執行(portal-demo-suite)─────────────────────── +// +// 為什麼:RAG self-hosted 實例不部署 kbdb-graph-plugin worker(graphBase 直連會 1042, +// Arcrun#57);graph/chat 這類查詢能力在該實例是以 tenant 的 named workflow 形式存在 +//(WEBHOOKS KV `{tenant}:wf:{name}`,與 /webhooks/named 同一資料源)。 +// 怎麼接:直接 import executeWebhookGraph(webhooks-named.ts trigger/query 端點同一個 +// 執行入口)在 **本 worker 進程內** 執行——絕不 fetch 自己的 hostname +//(global_fetch_strictly_public 下打自己=self-loop,見 /portal/data/workflows 同款註解)。 + +/** 讀 tenant 的 named workflow graph(`{tenant}:wf:{name}`)。不存在/壞 record → null。 */ +async function getTenantWorkflowGraph(env: Bindings, name: string): Promise | null> { + const raw = await env.WEBHOOKS.get(`${portalTenant(env)}:wf:${name}`, 'text'); + if (!raw) return null; + try { + const rec = JSON.parse(raw) as { graph?: Record }; + return rec.graph && typeof rec.graph === 'object' ? rec.graph : null; + } catch { + return null; // 壞 record 視同不存在(呼叫端各自誠實回報) + } +} + +/** workflow 最終節點輸出常見兩形:本體即結果,或再包一層 data(http_request 類零件慣例)。取有目標欄位的那層。 */ +function unwrapWorkflowData(data: unknown, key: string): Record { + const outer = data && typeof data === 'object' ? (data as Record) : {}; + if (key in outer) return outer; + const inner = outer.data; + if (inner && typeof inner === 'object' && key in (inner as Record)) { + return inner as Record; + } + return outer; +} + +/** + * graph_neighbors workflow 輸出 → 與 kbdb-graph-plugin 相同的回應形狀 {neighbors, edges, count} + *(前端 doGraphSearch 讀 neighbors/edges;count=neighbors 數,重算不信 workflow 自報)。 + * 純函式(單測用 export)。 + */ +export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; edges: unknown[]; count: number } { + const layer = unwrapWorkflowData(data, 'neighbors'); + const neighbors = Array.isArray(layer.neighbors) ? layer.neighbors : []; + const edges = Array.isArray(layer.edges) ? layer.edges : []; + return { neighbors, edges, count: neighbors.length }; +} + /** 越庫/不存在 一律同一句 404(不洩存在性)。 */ function notFound(c: Context<{ Bindings: Bindings }>): Response { return c.json({ error: '找不到這筆資料' }, 404); @@ -49,6 +94,28 @@ function canReadLibrary(userLibraries: string[], library: string): boolean { return userLibraries.includes('*') || userLibraries.includes(library); } +/** + * 搜尋殘影過濾(**Arcrun#46 上游修好前的 portal 端治標**——舊管線的 deprecated 產物還躺在 + * KBDB 裡污染搜尋結果;根治=上游清資料/重建索引,那修好後這段可整段拔掉)。 + * 濾掉:metadata_json.status === 'deprecated' 的 entry、content 以「(舊管線產物」開頭的 entry。 + * metadata_json parse 失敗 → 視為保留(治標不誤殺;壞 metadata ≠ deprecated)。 + * 純函式(單測用 export)。 + */ +export function filterDeprecatedEntries( + entries: T[], +): T[] { + return entries.filter((e) => { + try { + const meta = JSON.parse(e.metadata_json ?? 'null') as { status?: unknown } | null; + if (meta && meta.status === 'deprecated') return false; + } catch { + /* parse 失敗 → 保留 */ + } + if (String(e.content ?? '').startsWith('(舊管線產物')) return false; + return true; + }); +} + // GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic //(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形 //(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳—— @@ -77,7 +144,21 @@ portalDataRouter.get('/portal/data/search', (c) => if (limit && /^\d{1,3}$/.test(limit)) params.set('limit', limit); const res = await kbdbFetch(c.env, `/entries/search?${params.toString()}`); - return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + if (!res.ok) { + // 錯誤回應照原樣透傳(誠實,不加工) + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + } + // Arcrun#46 治標:server-side 濾掉舊管線 deprecated 殘影再回(count 重算)。 + // 上游修好(清資料/重建索引)後,這段連同 filterDeprecatedEntries 一起拔掉。 + const body = (await res.json().catch(() => null)) as + | { entries?: { metadata_json?: string | null; content?: string | null }[]; count?: number } + | null; + if (!body || !Array.isArray(body.entries)) { + // 回應不是預期形狀 → 照原樣回(不因治標把正常錯誤形狀吃掉) + return c.json(body ?? { error: 'KBDB 回應不是 JSON' }, body ? 200 : 502); + } + const entries = filterDeprecatedEntries(body.entries); + return c.json({ ...body, entries, count: entries.length }); }), ); @@ -106,7 +187,10 @@ portalDataRouter.get('/portal/data/entries/:id', (c) => // GET /portal/data/graph/neighbors/:name — graph 模式(D-4 粗閘): // 只對「擁有 graph 來源庫權限」的用戶開放;無權 → 403(SDD 明定,graph 粗閘是 404 紅線的例外)。 -// 放行後純轉發 kbdb-graph-plugin(token 只在 server 側,同 kbdb-proxy 慣例)。 +// 放行後的資料源二選一(Arcrun#57): +// ① tenant 有 `{tenant}:wf:graph_neighbors` workflow → in-process 執行它(RAG self-hosted +// 實例不部署 kbdb-graph-plugin,直連會 1042),輸出映射成與 plugin 相同形狀; +// ② 沒有 → fallback 原本 graphBase 直連 plugin(Mira/leo21c 實例相容,行為一字不變)。 portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => run(c, async () => { const auth = await requirePortalUser(c); @@ -115,6 +199,29 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => if (!(await hasGraphAccess(c.env, libraries))) { return c.json({ error: '無知識圖譜檢視權限' }, 403); } + + // ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant) + const tenant = portalTenant(c.env); + const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors'); + if (wfGraph) { + const depthRaw = c.req.query('depth') ?? ''; + const depth = /^\d{1,2}$/.test(depthRaw) ? Number(depthRaw) : 2; + const result = await executeWebhookGraph( + c.env, + wfGraph, + { node: c.req.param('name'), depth, namespace: tenant, owner: tenant }, + 'graph_neighbors', + tenant, + c.executionCtx, + ); + if (!result.success) { + // workflow 執行失敗 → 誠實 502(不假裝無關聯) + return c.json({ error: `graph_neighbors workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); + } + return c.json(mapGraphWorkflowOutput(result.data)); + } + + // ② plugin fallback(Mira/leo21c 相容) const base = graphBase(c.env); const headers: Record = {}; if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; @@ -128,6 +235,114 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => }), ); +// GET /portal/data/chat?question=... — AI 問答(portal-demo-suite)。 +// 設計哲學:AI 檢索=用戶手動搜尋同一套——同 search 的 requirePortalUser 閘、同一個租戶資料面, +// 只是把「人下關鍵字」換成「workflow 代查再作答」;前端不因走 AI 多拿任何權限。 +// 機制同 graph_neighbors:in-process 執行 tenant 的 rag_chat workflow(executeWebhookGraph, +// 絕不 fetch 自己 hostname);workflow 不存在 → 誠實 404,不假裝這實例有問答能力。 +portalDataRouter.get('/portal/data/chat', (c) => + run(c, async () => { + const auth = await requirePortalUser(c); + if (!auth.ok) return auth.res; + const question = c.req.query('question'); + if (!question) return c.json({ error: 'question 必填' }, 400); + + const wfGraph = await getTenantWorkflowGraph(c.env, 'rag_chat'); + if (!wfGraph) return c.json({ error: '此實例未安裝問答 workflow' }, 404); + + const result = await executeWebhookGraph( + c.env, + wfGraph, + { question }, + 'rag_chat', + portalTenant(c.env), + c.executionCtx, + ); + if (!result.success) { + // workflow 執行失敗 → 誠實 502(不把錯誤編成答案) + return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); + } + // 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造) + const inner = unwrapWorkflowData(result.data, 'answer'); + return c.json({ + answer: typeof inner.answer === 'string' ? inner.answer : '', + sources: Array.isArray(inner.sources) ? inner.sources : [], + graph_facts: inner.graph_facts ?? null, + }); + }), +); + +// ── 上傳(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 654f1d5..c9e5395 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 {
+ @@ -236,6 +237,17 @@ function renderPortalHtml(brand: string): string {
+ +
+
AI 問答・答案附出處,可回搜尋驗證
+
+ + +
+
+
@@ -255,6 +267,22 @@ function renderPortalHtml(brand: string): string {
載入中…
+ +
+
上傳Markdown / 純文字
+
+ 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。 +
+
+
把 .md / .txt 檔案拖放到這裡
+
+ + +
+
+
+
工作流唯讀・系統狀態
@@ -338,6 +366,7 @@ function renderPortalHtml(brand: string): string {
搜尋
+
上傳
工作流
管理
設定
@@ -468,14 +497,15 @@ function renderPortalHtml(brand: string): string { } // ── 路由 ── - var VIEWS = ['search', 'card', 'workflows', 'admin', 'settings']; + var VIEWS = ['search', 'card', 'upload', 'workflows', 'admin', 'settings']; var HOME = 'search'; function allowedViews() { - // 工作流/管理頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、 - // /portal/admin/* role 閘 403——前端藏只是 UX,curl 直打也繞不過) + // 工作流/管理/上傳頁按 session 能力顯示(真閘在 server:/portal/data/workflows 403/404、 + // /portal/admin/* role 閘 403、/portal/data/upload 未啟用 404——前端藏只是 UX,curl 直打也繞不過) return VIEWS.filter(function (v) { if (v === 'workflows') return !!(S.profile && S.profile.workflows_visible); if (v === 'admin') return !!(S.profile && S.profile.role === 'admin'); + if (v === 'upload') return !!(S.profile && S.profile.upload_enabled); return true; }); } @@ -524,6 +554,9 @@ function renderPortalHtml(brand: string): string { $('side-foot').innerHTML = esc(p.display_name || '') + '
' + esc(location.host); $('nav-workflows').classList.toggle('hide', !p.workflows_visible); $('tab-workflows').classList.toggle('hide', !p.workflows_visible); + // 上傳 nav:一般用戶可見,但只在實例啟用上傳(bindings 齊全)時顯示(未啟用的實例零影響) + $('nav-upload').classList.toggle('hide', !p.upload_enabled); + $('tab-upload').classList.toggle('hide', !p.upload_enabled); $('nav-admin').classList.toggle('hide', p.role !== 'admin'); $('tab-admin').classList.toggle('hide', p.role !== 'admin'); $('mode-graph').classList.toggle('hide', !p.graph_allowed); @@ -642,6 +675,56 @@ function renderPortalHtml(brand: string): string { if (t) nav('card', t.getAttribute('data-card')); }); + // ── AI 問答(portal-demo-suite)── + // 設計哲學:AI 檢索=用戶手動搜尋同一套——同 session token、同 /portal/data/* enforce 面, + // AI 只代查再作答;答案附 sources(mode+page),點 page 帶回搜尋框讓用戶自己驗證。 + $('ai-go').addEventListener('click', doAsk); + $('ai-q').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doAsk(); }); + function doAsk() { + var q = $('ai-q').value.trim(); + if (!q) { toast('請先輸入問題'); return; } + $('ai-go').disabled = true; + $('ai-out').innerHTML = '
AI 查閱知識庫中…
'; + fetch('/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + $('ai-go').disabled = false; + if (guard401(x.status)) return; + if (!x.ok) { + // 404=此實例未安裝問答 workflow(server 誠實回報,前端不假裝有 AI) + $('ai-out').innerHTML = '
問不到
' + esc(x.d.error || ('問答失敗(HTTP ' + x.status + ')')) + '
'; + return; + } + var srcs = x.d.sources || []; + // answer 純文字渲染、保留換行(pre-wrap+esc,不走 markdown、防 XSS) + var html = '
' + + '
' + esc(x.d.answer || '(AI 沒有給出答案)') + '
'; + if (srcs.length) { + html += '
出處(點頁名帶入搜尋框)
' + + '
' + srcs.map(function (s) { + var page = (s && (s.page || s.page_name)) || ''; + var mode = (s && s.mode) || ''; + return '
' + + (mode ? '' + esc(mode) + '' : '') + + '' + esc(page || '(無頁名)') + '' + + '
'; + }).join('') + '
'; + } + html += '
'; + $('ai-out').innerHTML = html; + }) + .catch(function (e) { $('ai-go').disabled = false; $('ai-out').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); + } + $('ai-out').addEventListener('click', function (ev) { + var t = ev.target.closest('[data-aisrc]'); + if (!t) return; + var page = t.getAttribute('data-aisrc'); + if (!page) return; + $('se-q').value = page; + $('se-q').focus(); + window.scrollTo(0, 0); + }); + // ── graph 模式(D-4 粗閘:無權者按鈕根本不顯示;就算 curl 直打 API 也是 403)── function doGraphSearch(name) { $('se-results').style.display = 'none'; @@ -749,6 +832,71 @@ function renderPortalHtml(brand: string): string { .catch(function (e) { $('wf-list').innerHTML = '
請求失敗:' + esc(friendlyErr(e)) + '
'; }); } + // ── 上傳頁(portal-demo-suite)── + // 前端只讀檔轉 base64 逐檔 POST /portal/data/upload;Gitea 位置與 token 全在 server 側, + // 這裡看不到任何 repo/token 字串。逐檔獨立狀態列(一檔失敗不拖累其他檔)。 + var UP_OK_MSG = '已收件,約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。'; + function b64FromDataUrl(dataUrl) { + var i = String(dataUrl).indexOf(','); + return i >= 0 ? String(dataUrl).slice(i + 1) : ''; + } + function uploadStatusRow(name) { + var row = document.createElement('div'); + row.className = 'card-item'; + row.innerHTML = '
' + + '' + esc(name) + '' + + '讀取中…
' + + '
'; + $('up-list').prepend(row); + return row; + } + function setRowStatus(row, ok, statusText, msg) { + var st = row.querySelector('.up-st'); + st.textContent = statusText; + st.className = 'up-st ' + (ok === true ? 'ok' : (ok === false ? 'err' : 'muted')); + row.querySelector('.up-msg').textContent = msg || ''; + } + function uploadOneFile(file) { + var row = uploadStatusRow(file.name); + if (!/\\.(md|txt)$/i.test(file.name)) { + setRowStatus(row, false, '已略過', '只收 .md / .txt 檔。'); + return; + } + var reader = new FileReader(); + reader.onerror = function () { setRowStatus(row, false, '讀檔失敗', '瀏覽器讀不到這個檔案。'); }; + reader.onload = function () { + setRowStatus(row, null, '上傳中…', ''); + fetch('/portal/data/upload', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), + body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) }) + }) + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (x) { + if (guard401(x.status)) return; + if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; } + setRowStatus(row, true, '已收件', UP_OK_MSG + (x.d.path ? '(' + x.d.path + ')' : '')); + }) + .catch(function (e) { setRowStatus(row, false, '失敗', friendlyErr(e)); }); + }; + reader.readAsDataURL(file); // dataURL 尾段即 base64(unicode 安全,免手刻 btoa 編碼) + } + function handleUploadFiles(files) { + for (var i = 0; i < files.length; i++) uploadOneFile(files[i]); + } + $('up-pick').addEventListener('click', function (ev) { ev.stopPropagation(); $('up-file').click(); }); + $('up-drop').addEventListener('click', function () { $('up-file').click(); }); + $('up-file').addEventListener('change', function () { handleUploadFiles(this.files); this.value = ''; }); + ['dragover', 'dragenter'].forEach(function (t) { + $('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.12)'; }); + }); + ['dragleave', 'drop'].forEach(function (t) { + $('up-drop').addEventListener(t, function (ev) { ev.preventDefault(); ev.currentTarget.style.background = 'rgba(var(--amber-rgb),.04)'; }); + }); + $('up-drop').addEventListener('drop', function (ev) { + if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) handleUploadFiles(ev.dataTransfer.files); + }); + // ── 管理頁(P4:帳號管理+庫目錄管理。全走 /portal/admin/*,前端零業務邏輯; // 一般用戶 nav 不顯示,且 server role 閘 403——藏只是 UX,真閘在後端)── var adminLibs = []; // 庫目錄快取(渲染每帳號的庫勾選用) diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index e485bb4..5fa1148 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -360,6 +360,15 @@ export function workflowsVisible(env: Bindings, role: string): boolean { return role === 'admin'; } +/** + * 上傳能力(portal-demo-suite):PORTAL_UPLOAD_REPO / PORTAL_UPLOAD_GITEA / PORTAL_UPLOAD_TOKEN + * 三個 bindings **齊全**才啟用。Mira 零影響:未設=功能不存在(/portal/data/upload 404、 + * 前端 nav 隱藏)。這裡只是顯示提示——真閘在 /portal/data/upload 路由層,前端藏不藏都繞不過。 + */ +export function uploadEnabled(env: Bindings): boolean { + return Boolean(env.PORTAL_UPLOAD_REPO && env.PORTAL_UPLOAD_GITEA && env.PORTAL_UPLOAD_TOKEN); +} + /** * admin 操作目標 record 的成員資格驗證:record 的 email head entry(子 namespace 內) * 必須指回同一 record_id——同時證明「是 portal_user」且「在本實例的 {tenant}::portal 下」, @@ -482,6 +491,8 @@ portalRouter.get('/portal/session', (c) => libraries, graph_allowed: await hasGraphAccess(c.env, libraries), workflows_visible: workflowsVisible(c.env, role), + // portal-demo-suite:上傳頁能力(bindings 齊全才 true;同上,只是顯示提示,真閘在路由層) + upload_enabled: uploadEnabled(c.env), }); }), ); diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 89abcbe..e38fadd 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -110,6 +110,15 @@ export type Bindings = { // 路由層 enforce 在 /portal/data/workflows(無權 403、off 404),前端只照 /portal/session // 的 workflows_visible 顯示或隱藏 nav 項。壞值退回 admin(不因 typo 意外全開)。 PORTAL_SHOW_WORKFLOWS?: string; + // ── Portal 上傳(portal-demo-suite,三者齊全才啟用;任一缺 → /portal/data/upload 404 + // 「未啟用上傳」且前端 nav 不顯示——Mira 零影響:未設=功能不存在,行為一字不變)── + // 目標 Gitea repo(owner/repo,如 "Leo/arcrun-rag-demo-knowledge")。上傳落 docs/{filename}。 + PORTAL_UPLOAD_REPO?: string; + // Gitea base URL(如 https://git.uncle6.me)。刻意不共用 GITEA_BASE_URL(那是駕駛艙 sprint + // 資料源、建議唯讀 token)——上傳要寫入權限,租戶/scope 都不同,混用會互相牽制。 + PORTAL_UPLOAD_GITEA?: string; + // 能寫該 repo contents API 的 token(wrangler secret)。**只在 server 側,永不下發前端**。 + PORTAL_UPLOAD_TOKEN?: string; // kbdb-graph-plugin worker base URL(可選)。未設 → 用 WORKER_SUBDOMAIN 現算 // https://kbdb-graph-plugin..workers.dev(該 repo wrangler.toml name 固定)。 // console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。 diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 53f2735..b5836dd 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -19,7 +19,7 @@ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { workflowsVisible } from '../src/routes/portal'; -import { entryLibrary } from '../src/routes/portal-data'; +import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; @@ -353,5 +353,58 @@ describe('GET /portal/session(P3 能力欄位)', () => { const data = (await res.json()) as Record; expect(data.graph_allowed).toBe(true); expect(data.workflows_visible).toBe(true); + // portal-demo-suite:測試環境未設 upload bindings → upload_enabled=false(Mira 零影響預設) + expect(data.upload_enabled).toBe(false); + }); +}); + +// ═══════════════ 7. portal-demo-suite 純函式 ═══════════════ + +describe('sanitizeUploadFilename(上傳檔名驗證)', () => { + it('去路徑分隔(擋穿越)、.txt 改 .md、無副檔名補 .md', () => { + expect(sanitizeUploadFilename('notes.md')).toBe('notes.md'); + expect(sanitizeUploadFilename('memo.txt')).toBe('memo.md'); + expect(sanitizeUploadFilename('README')).toBe('README.md'); + expect(sanitizeUploadFilename('../../etc/passwd')).toBe('passwd.md'); // 只取最後一段,穿越失效 + expect(sanitizeUploadFilename('a\\b\\c.md')).toBe('c.md'); + expect(sanitizeUploadFilename('中文筆記.txt')).toBe('中文筆記.md'); + }); + it('空名/純路徑/隱藏檔/超過 100 字/非字串 → null', () => { + expect(sanitizeUploadFilename('')).toBe(null); + expect(sanitizeUploadFilename(' ')).toBe(null); + expect(sanitizeUploadFilename('docs/')).toBe(null); + expect(sanitizeUploadFilename('.env')).toBe(null); + expect(sanitizeUploadFilename('a'.repeat(120) + '.md')).toBe(null); + expect(sanitizeUploadFilename(42)).toBe(null); + expect(sanitizeUploadFilename(undefined)).toBe(null); + }); +}); + +describe('filterDeprecatedEntries(Arcrun#46 搜尋殘影治標)', () => { + it('濾 status=deprecated 與「(舊管線產物」開頭;metadata parse 失敗保留', () => { + const keepNormal = { metadata_json: '{"library":"general"}', content: '正常內容' }; + const keepBadMeta = { metadata_json: 'not-json{{', content: '壞 metadata 不誤殺' }; + const keepNullMeta = { metadata_json: null, content: '無 metadata' }; + const dropByStatus = { metadata_json: '{"status":"deprecated"}', content: '看起來正常但已標廢' }; + const dropByContent = { metadata_json: '{}', content: '(舊管線產物)殘影條目' }; + const out = filterDeprecatedEntries([keepNormal, dropByStatus, keepBadMeta, dropByContent, keepNullMeta]); + expect(out).toEqual([keepNormal, keepBadMeta, keepNullMeta]); + }); + it('空陣列 → 空陣列', () => { + expect(filterDeprecatedEntries([])).toEqual([]); + }); +}); + +describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', () => { + it('本體有 neighbors → 直取;count 重算不信自報', () => { + const out = mapGraphWorkflowOutput({ neighbors: ['a', 'b'], edges: [{ subject: 'a', predicate: 'rel', object: 'b' }], count: 99 }); + expect(out.neighbors).toEqual(['a', 'b']); + expect(out.edges.length).toBe(1); + expect(out.count).toBe(2); + }); + it('包一層 data(http_request 慣例)→ 取內層;非物件/缺欄位 → 誠實空集合', () => { + expect(mapGraphWorkflowOutput({ data: { neighbors: ['x'], edges: [] } })).toEqual({ neighbors: ['x'], edges: [], count: 1 }); + expect(mapGraphWorkflowOutput(null)).toEqual({ neighbors: [], edges: [], count: 0 }); + expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 }); }); });