From 8accdacc3cf67356c845165c1c5325b62b24e37a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:28:04 +0000 Subject: [PATCH] =?UTF-8?q?portal=20=E6=90=9C=E5=B0=8B=E6=BF=BE=E6=AE=98?= =?UTF-8?q?=E5=BD=B1=EF=BC=88#46=20=E6=B2=BB=E6=A8=99=EF=BC=89=EF=BC=8B?= =?UTF-8?q?=E5=9B=9B=E4=BB=B6=E5=A5=97=E5=96=AE=E6=B8=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /portal/data/search 拿到 KBDB 回應後 server-side 過濾 metadata_json.status= 'deprecated' 與 content 以「(舊管線產物」開頭的 entries(metadata parse 失敗 視為保留,不誤殺),count 重算。這是 Arcrun#46 上游修好前的 portal 端治標, 上游清完資料後整段(含 filterDeprecatedEntries)可拔。 單測:sanitizeUploadFilename(路徑穿越/副檔名/長度)、filterDeprecatedEntries (濾殘影/壞 metadata 保留)、mapGraphWorkflowOutput(#57 輸出映射); /portal/session 補 upload_enabled=false 斷言。另修 portal-ui 一處註解字樣 (誤用產品名,撞零 Mira 斷言)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5 --- cypher-executor/src/routes/portal-data.ts | 38 +++++++++++++++- cypher-executor/src/routes/portal-ui.ts | 2 +- cypher-executor/tests/portal-data.test.ts | 55 ++++++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 6c9aee7..0a628b4 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -94,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 誠實透傳—— @@ -122,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 }); }), ); diff --git a/cypher-executor/src/routes/portal-ui.ts b/cypher-executor/src/routes/portal-ui.ts index c2af9a9..c9e5395 100644 --- a/cypher-executor/src/routes/portal-ui.ts +++ b/cypher-executor/src/routes/portal-ui.ts @@ -554,7 +554,7 @@ 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 齊全)時顯示(Mira 零影響) + // 上傳 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'); 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 }); }); });