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)。
|
||||
|
||||
@@ -215,6 +215,7 @@ function renderPortalHtml(brand: string): string {
|
||||
<div id="sidenav">
|
||||
<div class="logo">${brand}</div>
|
||||
<div class="nav" data-nav="search">搜尋</div>
|
||||
<div class="nav hide" id="nav-upload" data-nav="upload">上傳</div>
|
||||
<div class="nav hide" id="nav-workflows" data-nav="workflows">工作流</div>
|
||||
<div class="nav hide" id="nav-admin" data-nav="admin">管理</div>
|
||||
<div class="nav" data-nav="settings">設定</div>
|
||||
@@ -266,6 +267,22 @@ function renderPortalHtml(brand: string): string {
|
||||
<div id="cd-main" style="padding:24px 0 40px"><div class="muted">載入中…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 上傳(portal-demo-suite:upload bindings 齊全才顯示 nav——/portal/session 的
|
||||
upload_enabled;藏只是 UX,真閘在 /portal/data/upload 路由層 404)-->
|
||||
<div class="view page narrow" id="v-upload">
|
||||
<div class="pagehead"><span class="t">上傳</span><span class="m">Markdown / 純文字</span></div>
|
||||
<div style="margin-top:16px;padding:13px 16px;border-radius:11px;background:rgba(var(--amber-rgb),.06);border:1px dashed rgba(var(--amber-rgb),.35);font-size:14px;line-height:1.7;color:rgba(var(--ink-rgb),.65)">
|
||||
上傳的文件會進入知識庫收件管線:<b style="color:var(--ink)">約 1 分鐘後可在搜尋頁找到</b>;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。
|
||||
</div>
|
||||
<div id="up-drop" style="margin-top:16px;padding:44px 20px;border-radius:13px;border:2px dashed rgba(var(--amber-rgb),.4);background:rgba(var(--amber-rgb),.04);text-align:center;cursor:pointer">
|
||||
<div style="font-size:16px;color:rgba(var(--ink-rgb),.7)">把 .md / .txt 檔案拖放到這裡</div>
|
||||
<div class="muted" style="margin-top:8px;font-size:14px">或</div>
|
||||
<button class="btn2" id="up-pick" style="margin-top:10px;padding:11px 22px;font-size:15px">選擇檔案(可多選)</button>
|
||||
<input type="file" id="up-file" accept=".md,.txt" multiple style="display:none">
|
||||
</div>
|
||||
<div class="listcol" id="up-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- 工作流(D-8:預設 admin 才看得到;唯讀,不開 trigger)-->
|
||||
<div class="view page narrow" id="v-workflows">
|
||||
<div class="pagehead"><span class="t">工作流</span><span class="m">唯讀・系統狀態</span></div>
|
||||
@@ -349,6 +366,7 @@ function renderPortalHtml(brand: string): string {
|
||||
<!-- 手機底部導覽 -->
|
||||
<div id="tabbar">
|
||||
<div class="tab" data-nav="search">搜尋</div>
|
||||
<div class="tab hide" id="tab-upload" data-nav="upload">上傳</div>
|
||||
<div class="tab hide" id="tab-workflows" data-nav="workflows">工作流</div>
|
||||
<div class="tab hide" id="tab-admin" data-nav="admin">管理</div>
|
||||
<div class="tab" data-nav="settings">設定</div>
|
||||
@@ -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 || '') + '<br>' + 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 = '<div class="err">請求失敗:' + esc(friendlyErr(e)) + '</div>'; });
|
||||
}
|
||||
|
||||
// ── 上傳頁(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 = '<div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap">' +
|
||||
'<span class="mono" style="font-size:14.5px;word-break:break-all">' + esc(name) + '</span>' +
|
||||
'<span class="up-st muted" style="margin-left:auto;font-size:14px;flex:none">讀取中…</span></div>' +
|
||||
'<div class="up-msg" style="margin-top:6px;font-size:13.5px;line-height:1.65;color:rgba(var(--ink-rgb),.6)"></div>';
|
||||
$('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 = []; // 庫目錄快取(渲染每帳號的庫勾選用)
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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.<subdomain>.workers.dev(該 repo wrangler.toml name 固定)。
|
||||
// console 卡片詳頁「關聯視圖」經 cypher proxy 打它(kbdb-proxy.ts /kbdb/graph/neighbors/:name)。
|
||||
|
||||
Reference in New Issue
Block a user