From fde182792806b1641bc21d0d4583e6a4cb2a18c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 06:24:14 +0000 Subject: [PATCH] =?UTF-8?q?portal=20=E4=B8=8A=E5=82=B3=E9=A0=81=EF=BC=9APO?= =?UTF-8?q?ST=20/portal/data/upload=EF=BC=88server-side=20=E5=AF=AB=20Gite?= =?UTF-8?q?a=EF=BC=89=EF=BC=8B=E5=89=8D=E7=AB=AF=E6=8B=96=E6=94=BE?= =?UTF-8?q?=E4=B8=8A=E5=82=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BH7LdhuCdVUHfHXbM7N8r5 --- cypher-executor/src/routes/portal-data.ts | 73 +++++++++++++++++- cypher-executor/src/routes/portal-ui.ts | 93 ++++++++++++++++++++++- cypher-executor/src/routes/portal.ts | 11 +++ cypher-executor/src/types.ts | 9 +++ 4 files changed, 182 insertions(+), 4 deletions(-) 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 {
+ @@ -266,6 +267,22 @@ function renderPortalHtml(brand: string): string {
載入中…
+ +
+
上傳Markdown / 純文字
+
+ 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。 +
+
+
把 .md / .txt 檔案拖放到這裡
+
+ + +
+
+
+
工作流唯讀・系統狀態
@@ -349,6 +366,7 @@ function renderPortalHtml(brand: string): string {
搜尋
+
上傳
工作流
管理
設定
@@ -479,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; }); } @@ -535,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 齊全)時顯示(Mira 零影響) + $('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); @@ -810,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)。