From 6d0cae593e8a331532b4918b7bf266c433b676bd Mon Sep 17 00:00:00 2001 From: vivekshetye Date: Tue, 26 May 2026 01:37:33 -0700 Subject: [PATCH] feat(kanban-artifacts): add kanban task artifacts browser plugin - Dashboard plugin with 3-column layout: boards sidebar, file list, file preview - Browse kanban task workspaces with file content preview (markdown, code, images) - Per-board task caching to prevent cross-board contamination when multiple boards are expanded simultaneously - Support for the 'default' board (root kanban.db) alongside per-board databases - Simplified Bearer token auth compatible with Hermes dashboard session tokens - API endpoints: /boards, /boards//tasks, /tasks//files, /files - Features: search/filter tasks, column resizing, file download, workspace browsing --- kanban-artifacts/README.md | 47 ++ kanban-artifacts/dashboard/dist/index.js | 1 + kanban-artifacts/dashboard/dist/style.css | 9 + kanban-artifacts/dashboard/index.js | 569 ++++++++++++++++++++++ kanban-artifacts/dashboard/manifest.json | 14 + kanban-artifacts/dashboard/plugin_api.py | 336 +++++++++++++ 6 files changed, 976 insertions(+) create mode 100644 kanban-artifacts/README.md create mode 100644 kanban-artifacts/dashboard/dist/index.js create mode 100644 kanban-artifacts/dashboard/dist/style.css create mode 100644 kanban-artifacts/dashboard/index.js create mode 100644 kanban-artifacts/dashboard/manifest.json create mode 100644 kanban-artifacts/dashboard/plugin_api.py diff --git a/kanban-artifacts/README.md b/kanban-artifacts/README.md new file mode 100644 index 0000000..b76a204 --- /dev/null +++ b/kanban-artifacts/README.md @@ -0,0 +1,47 @@ +# kanban-artifacts + +Dashboard plugin for browsing and previewing output files from Hermes Kanban task workspaces. + +## What it does + +Adds a **Kanban Artifacts** tab to the Hermes dashboard (positioned after the built-in Kanban tab). Users can: + +- Browse boards and tasks from the Kanban system +- Explore artifact files in each task's workspace (`~/.hermes/kanban/workspaces/`) +- Preview text, code, markdown, images, and other common file types inline +- Download raw files with streaming support (50 MB cap) + +## Security + +- All API routes require a valid session token (`X-Hermes-Session-Token` or `Authorization: Bearer`) +- Path traversal is blocked — only `~/.hermes/kanban/workspaces/` and `~/.hermes/kanban/boards/` are accessible +- Files larger than 50 MB cannot be served via `/raw` +- SQL injection is prevented with parameterized queries +- Markdown content is HTML-escaped before rendering to prevent XSS + +## File structure + +``` +kanban-artifacts/dashboard/ + manifest.json — Dashboard plugin manifest (tab, icon, entry point, API file) + plugin_api.py — FastAPI backend (list boards/tasks/files, serve raw files) + index.js — React frontend source + dist/ + index.js — Built frontend bundle + style.css — Styles +``` + +## Installing + +Copy the `kanban-artifacts/` directory to: + +``` +~/.hermes/plugins/kanban-artifacts/dashboard/ +``` + +The plugin is auto-discovered by the Hermes dashboard on next startup. + +## Reference + +- [Hermes Plugin Proposal #8994](https://github.com/NousResearch/hermes-agent/issues/8994) +- Tracking issue: [Proposal: kanban-artifacts #32473](https://github.com/NousResearch/hermes-agent/issues/32473) \ No newline at end of file diff --git a/kanban-artifacts/dashboard/dist/index.js b/kanban-artifacts/dashboard/dist/index.js new file mode 100644 index 0000000..2d03316 --- /dev/null +++ b/kanban-artifacts/dashboard/dist/index.js @@ -0,0 +1 @@ +(()=>{(function(){"use strict";let p=window.__HERMES_PLUGIN_SDK__;if(!p)return;let{React:F}=p,e=F.createElement,{Card:fe,CardContent:ge,Badge:O,Button:M,Input:P,Label:xe,Select:he,SelectOption:be,Separator:ve,Tabs:ye,TabsList:we,TabsTrigger:Ne}=p.components,{useState:c,useEffect:v,useCallback:ke,useMemo:Q,useRef:$e}=p.hooks,{cn:x,timeAgo:Ce}=p.utils,J=p.useI18n||function(){return{t:{kanbanArtifacts:null},locale:"en"}};function k(s,n,o){let r=s&&s.kanbanArtifacts;if(r){let u=n.split(".");for(let i=0;i/g,">").replace(/"/g,""")}function $(s){return s<1024?s+" B":s<1024*1024?(s/1024).toFixed(1)+" KB":s<1024*1024*1024?(s/(1024*1024)).toFixed(1)+" MB":(s/(1024*1024*1024)).toFixed(2)+" GB"}function V(s){return{todo:"bg-gray-400",in_progress:"bg-blue-500",running:"bg-yellow-500",done:"bg-green-500",completed:"bg-green-500",blocked:"bg-red-500"}[s]||"bg-gray-400"}function C(s){let n=s.split(".").pop().toLowerCase();return{md:"\u{1F4DD}",markdown:"\u{1F4DD}",txt:"\u{1F4C4}",log:"\u{1F4C4}",py:"\u{1F40D}",js:"\u{1F7E8}",ts:"\u{1F537}",jsx:"\u269B\uFE0F",tsx:"\u269B\uFE0F",html:"\u{1F310}",css:"\u{1F3A8}",scss:"\u{1F3A8}",json:"\u{1F4CB}",yaml:"\u2699\uFE0F",yml:"\u2699\uFE0F",toml:"\u2699\uFE0F",png:"\u{1F5BC}\uFE0F",jpg:"\u{1F5BC}\uFE0F",jpeg:"\u{1F5BC}\uFE0F",gif:"\u{1F5BC}\uFE0F",webp:"\u{1F5BC}\uFE0F",svg:"\u{1F5BC}\uFE0F",pdf:"\u{1F4D5}",zip:"\u{1F5DC}\uFE0F",tar:"\u{1F5DC}\uFE0F",gz:"\u{1F5DC}\uFE0F",sh:"\u{1F4DF}",bash:"\u{1F4DF}",sql:"\u{1F5C3}\uFE0F",csv:"\u{1F4CA}"}[n]||"\u{1F4CE}"}let Y=new Set(["py","js","ts","jsx","tsx","html","css","scss","sass","json","yaml","yml","toml","xml","sql","sh","bash","zsh","fish","r","lua","pl","rb","go","rs","c","cpp","h","hpp","java","kt","swift","proto","graphql","gql","md","markdown","txt","log","env"]),Z={py:"python",js:"javascript",ts:"typescript",jsx:"javascript",tsx:"typescript",html:"html",css:"css",scss:"scss",json:"json",yaml:"yaml",yml:"yaml",xml:"xml",sql:"sql",sh:"bash",bash:"bash",md:"markdown",txt:"plaintext",r:"r",rb:"ruby",rs:"rust",go:"go",c:"c",cpp:"cpp",h:"c",hpp:"cpp",java:"java",kt:"kotlin",swift:"swift",proto:"protobuf",graphql:"graphql"};function j({className:s}){return e("div",{className:x("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent",s)})}function ee({file:s,active:n,onClick:o}){return e("div",{className:x("flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors",n?"bg-blue-500/20 text-blue-400":"hover:bg-white/5 text-gray-200"),onClick:o},e("span",{className:"text-base"},C(s.name)),e("span",{className:"flex-1 truncate"},s.name),e("span",{className:"text-gray-500 text-xs shrink-0"},$(s.size)))}function te(){let{t:s}=J(),[n,o]=c([]),[r,u]=c([]),[i,T]=c({}),[Me,De]=c({}),[h,B]=c(null),[l,ae]=c(null),[d,b]=c(null),[I,f]=c(null),[re,R]=c(!0),[le,S]=c(!1),[oe,_]=c(!1),[w,ce]=c(""),[D,E]=c(null),[q,A]=c({}),[L,ie]=c({board:280,file:220}),[Se,U]=c(null),g=F.useRef(null),H=t=>{if(!g.current)return;t.preventDefault();let a=g.current.startX,m=g.current.startWidths,N=t.clientX-a,z={...m};g.current.side==="board-file"?z.board=Math.max(160,m.board+N):z.file=Math.max(140,m.file+N),ie(z)},K=()=>{U(null),g.current=null,document.body.style.userSelect="",document.body.style.cursor="",document.removeEventListener("mousemove",H),document.removeEventListener("mouseup",K)},W=(t,a)=>{a.preventDefault(),g.current={startX:a.clientX,startWidths:{...L},side:t},U(t),document.body.style.userSelect="none",document.body.style.cursor="col-resize",document.addEventListener("mousemove",H),document.addEventListener("mouseup",K)};v(()=>{y("/boards").then(t=>{if(o(t),R(!1),t.length>0&&!h){let a=t[0].slug;B(a),A({[a]:!0})}}).catch(()=>R(!1))},[]),v(()=>{if(!h)return;if(Me[h]){u(Me[h]);return;}y(`/boards/${h}/tasks`).then(data=>{De(prev=>({...prev,[h]:data}));u(data)}).catch(()=>u([]))},[h]),v(()=>{if(!l){b(null),f(null);return}if(i[l.id]!==void 0){b(null),f(null);return}S(!0),b(null),f(null);let a=l.workspace_path?`/tasks/${l.id}/files?path=${encodeURIComponent(l.workspace_path)}`:`/tasks/${l.id}/files`;y(a).then(m=>{T(N=>({...N,[l.id]:m})),S(!1)}).catch(()=>{T(m=>({...m,[l.id]:[]})),S(!1)})},[l]),v(()=>{if(!d){f(null),E(null);return}_(!0),E(null),y(`/files?path=${encodeURIComponent(d.path)}`).then(t=>{f(t),_(!1)}).catch(t=>{E(String(t)),_(!1)})},[d]);function ue(t){A(a=>({...a,[t]:!a[t]})),B(t)}function de(t){ae(t),b(null),f(null)}function me(t){b(t)}let X=Q(()=>{if(!w)return r;let t=w.toLowerCase();return r.filter(a=>(a.title||"").toLowerCase().includes(t)||(a.id||"").toLowerCase().includes(t))},[r,w]),G=l?i[l.id]||[]:[],pe=l?e("div",{className:"flex items-center gap-2 text-xs text-gray-400 mt-1"},e(O,{variant:l.status==="done"?"success":"secondary",className:"text-xs"},l.status),e("span",null,l.assignee||"unassigned")):null;return e("div",{className:"flex h-full overflow-hidden"},e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.board}},e("div",{className:"p-3 border-b border-white/10"},e("div",{className:"font-semibold text-sm text-gray-200 mb-2"},k(s,"title","Kanban Artifacts")),e(P,{placeholder:k(s,"filter","Filter tasks\u2026"),value:w,onChange:t=>ce(t.target.value),className:"h-8 text-xs"})),e("div",{className:"flex-1 overflow-y-auto p-2"},re?e("div",{className:"flex justify-center py-8"},e(j)):n.length===0?e("p",{className:"text-xs text-gray-500 px-2"},"No boards found"):n.map(t=>e("div",{key:t.slug,className:"mb-1"},e("div",{className:x("flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer","text-gray-400 hover:text-gray-200 select-none transition-colors",h===t.slug&&"text-gray-200"),onClick:()=>ue(t.slug)},e("span",{className:"text-gray-500 transition-transform",style:{transform:q[t.slug]?"rotate(90deg)":"none"}},"\u25B6"),e("span",null,t.slug)),q[t.slug]&&e("div",{className:"mt-1"},(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).length===0?e("p",{className:"text-xs text-gray-600 px-2 py-1"},"No tasks"):(Me[t.slug]||[]).filter(a=>(a.title||"").toLowerCase().includes(w)||(a.id||"").toLowerCase().includes(w)).map(a=>e("div",{key:a.id,className:x("flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors","hover:bg-white/5",l&&l.id===a.id&&"bg-blue-500/15"),onClick:()=>de(a)},e("span",{className:x("w-2 h-2 rounded-full mt-0.5 shrink-0",V(a.status))}),e("div",{className:"flex-1 min-w-0"},e("div",{className:"text-xs text-gray-300 truncate"},a.title||"(untitled)"),e("div",{className:"text-gray-600 text-xs truncate"},a.id))))))))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("board-file",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"shrink-0 border-r border-white/10 flex flex-col overflow-hidden",style:{width:L.file}},e("div",{className:"p-3 border-b border-white/10"},l?e("div",null,e("div",{className:"text-sm font-medium text-gray-200 truncate"},l.title||"(untitled)"),pe):e("div",{className:"text-xs text-gray-500"},k(s,"selectTask","Select a task"))),e("div",{className:"flex-1 overflow-y-auto p-1"},l?le?e("div",{className:"flex justify-center py-8"},e(j)):G.length===0?e("p",{className:"text-xs text-gray-600 text-center py-8"},"No files"):G.map(t=>e(ee,{key:t.path,file:t,active:d&&d.path===t.path,onClick:()=>me(t)})):e("p",{className:"text-xs text-gray-600 text-center py-8"},"\u2190 Select a task"))),e("div",{className:"shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative",style:{width:6},onMouseDown:t=>W("file-preview",t),onClick:t=>t.preventDefault()},e("div",{className:"w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors"}),e("div",{className:"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center",style:{pointerEvents:"none"}},e("div",{className:"w-0.5 h-3 rounded-full bg-black/30"}))),e("div",{className:"flex-1 flex flex-col overflow-hidden"},d&&e("div",{className:"flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20"},e("div",{className:"flex items-center gap-2 min-w-0"},e("span",{className:"text-sm"},C(d.name)),e("span",{className:"text-sm text-gray-200 truncate"},d.name)),e("div",{className:"flex gap-2 shrink-0"},e(M,{variant:"ghost",size:"sm",className:"text-xs h-7",onClick:()=>{let t=document.createElement("a");t.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(d.path)}`,t.download=d.name,t.click()}},"\u2B07 Download"))),e("div",{className:"flex-1 overflow-y-auto"},d?oe?e("div",{className:"flex justify-center items-center h-full"},e(j)):D?e("div",{className:"text-red-400 text-sm p-4"},"Error: "+D):I&&se(I,d):e("div",{className:"flex items-center justify-center h-full text-gray-500 text-sm"},"\u2190 Select a file to preview"))))}function se(s,n){let o=n.name.split(".").pop().toLowerCase(),r=["md","markdown"].includes(o),u=Y.has(o);if(s.mime&&s.mime.startsWith("image/"))return e("div",{className:"p-4 flex justify-center"},e("img",{src:`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,alt:n.name,className:"max-w-full rounded-lg border border-white/10"}));if(s.text!==void 0&&s.text!==null){let i=s.truncated?e("div",{className:"text-yellow-500 text-xs px-4 pt-3"},`\u26A0 File truncated (${$(s.size)}). Download to view full content.`):null;return r?e("div",{className:"p-4 max-w-3xl"},e("div",{className:"prose prose-invert prose-sm max-w-none",dangerouslySetInnerHTML:{__html:ne(s.text)}}),i):u?e("div",{className:"p-4"},e("pre",{className:"bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm"},e("code",{className:`language-${Z[o]||o}`},s.text)),i):e("div",{className:"p-4"},e("pre",{className:"text-sm text-gray-300 whitespace-pre-wrap break-all font-mono"},s.text),i)}return e("div",{className:"flex flex-col items-center justify-center h-full gap-3 text-gray-400"},e("div",{className:"text-5xl"},C(n.name)),e("div",{className:"text-sm"},n.name),e("div",{className:"text-xs text-gray-600"},`${$(s.size)} \xB7 ${s.mime}`),e(M,{variant:"outline",size:"sm",className:"mt-2",onClick:()=>{let i=document.createElement("a");i.href=`/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(n.path)}`,i.download=n.name,i.click()}},"\u2B07 Download"))}function ne(s){return s?s.replace(/&/g,"&").replace(//g,">").replace(/```(\w*)\n([\s\S]*?)```/g,(n,o,r)=>`
${r.replace(/\n/g,"
")}
`).replace(/`([^`]+)`/g,"$1").replace(/^### (.+)$/gm,"

$1

").replace(/^## (.+)$/gm,"

$1

").replace(/^# (.+)$/gm,"

$1

").replace(/\*\*([^*]+)\*\*/g,"$1").replace(/\*([^*]+)\*/g,"$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g,'$1').replace(/^> (.+)$/gm,'
$1
').replace(/^---$/gm,"
").replace(/^[-*] (.+)$/gm,"
  • $1
  • ").replace(/^\d+\. (.+)$/gm,"
  • $1
  • ").replace(/(
  • .*<\/li>\n?)+/g,n=>`
      ${n}
    `).replace(/\n\n([^<])/g,"

    $1").replace(/\n/g,"
    "):""}window.__HERMES_PLUGINS__.register("kanban-artifacts",te)})();})(); diff --git a/kanban-artifacts/dashboard/dist/style.css b/kanban-artifacts/dashboard/dist/style.css new file mode 100644 index 0000000..b6c8510 --- /dev/null +++ b/kanban-artifacts/dashboard/dist/style.css @@ -0,0 +1,9 @@ +/* Kanban Artifacts — dashboard plugin styles */ + +:root { + --plugin-accent: #3b82f6; +} + +[data-plugin="kanban-artifacts"] { + font-family: inherit; +} \ No newline at end of file diff --git a/kanban-artifacts/dashboard/index.js b/kanban-artifacts/dashboard/index.js new file mode 100644 index 0000000..f7dd2e7 --- /dev/null +++ b/kanban-artifacts/dashboard/index.js @@ -0,0 +1,569 @@ +/** + * Hermes Kanban Artifact Viewer — Dashboard Plugin + * + * Plain IIFE using window.__HERMES_PLUGIN_SDK__ for React + shadcn primitives. + * Registers at /kanban-artifacts tab. + * + * SDK: + * window.__HERMES_PLUGINS__.register(name, Component) + * window.__HERMES_PLUGIN_SDK__.api.fetchJSON(path) → /api/plugins/kanban-artifacts/ + * window.__HERMES_PLUGIN_SDK__.components.* (Card, Badge, Button, Tabs, etc.) + * window.__HERMES_PLUGIN_SDK__.hooks.* (useState, useEffect, useCallback, useMemo) + * window.__HERMES_PLUGIN_SDK__.utils.* (cn, timeAgo, isoTimeAgo) + */ + +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK) return; + + const { React } = SDK; + const h = React.createElement; + + const { + Card, CardContent, + Badge, Button, Input, + Label, Select, SelectOption, + Separator, Tabs, TabsList, TabsTrigger, + } = SDK.components; + + const { useState, useEffect, useCallback, useMemo, useRef } = SDK.hooks; + const { cn, timeAgo } = SDK.utils; + + const useI18n = SDK.useI18n || function () { + return { t: { kanbanArtifacts: null }, locale: "en" }; + }; + + function tx(t, path, fallback) { + let node = t && t.kanbanArtifacts; + if (node) { + const parts = path.split("."); + for (let i = 0; i < parts.length; i++) { + if (node && typeof node === "object" && parts[i] in node) { + node = node[parts[i]]; + } else { node = null; break; } + } + } + return (typeof node === "string") ? node : fallback; + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + + async function apiFetch(path) { + // Plugin routes live under /api/plugins// + // The session token lives in window.__HERMES_SESSION_TOKEN__ (set by the dashboard SPA). + // We can't use credentials: 'include' with a cookie that doesn't exist — instead + // we forward the token explicitly as an Authorization header. + const url = `/api/plugins/kanban-artifacts${path}`; + const headers = { "Content-Type": "application/json" }; + const token = window.__HERMES_SESSION_TOKEN__; + if (token) headers["Authorization"] = `Bearer ${token}`; + const resp = await fetch(url, { credentials: "include", headers }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + } + + function escHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatSize(bytes) { + if (bytes < 1024) return bytes + " B"; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + } + + function statusDotClass(status) { + const map = { + "todo": "bg-gray-400", + "in_progress": "bg-blue-500", + "running": "bg-yellow-500", + "done": "bg-green-500", + "completed": "bg-green-500", + "blocked": "bg-red-500", + }; + return map[status] || "bg-gray-400"; + } + + function fileIcon(name) { + const ext = name.split(".").pop().toLowerCase(); + const icons = { + md: "📝", markdown: "📝", txt: "📄", log: "📄", + py: "🐍", js: "🟨", ts: "🔷", jsx: "⚛️", tsx: "⚛️", + html: "🌐", css: "🎨", scss: "🎨", + json: "📋", yaml: "⚙️", yml: "⚙️", toml: "⚙️", + png: "🖼️", jpg: "🖼️", jpeg: "🖼️", gif: "🖼️", webp: "🖼️", svg: "🖼️", + pdf: "📕", + zip: "🗜️", tar: "🗜️", gz: "🗜️", + sh: "📟", bash: "📟", + sql: "🗃️", csv: "📊", + }; + return icons[ext] || "📎"; + } + + const CODE_EXTS = new Set([ + "py", "js", "ts", "jsx", "tsx", "html", "css", "scss", "sass", + "json", "yaml", "yml", "toml", "xml", "sql", "sh", "bash", + "zsh", "fish", "r", "lua", "pl", "rb", "go", "rs", "c", "cpp", + "h", "hpp", "java", "kt", "swift", "proto", "graphql", "gql", + "md", "markdown", "txt", "log", "env", + ]); + + const LANG_MAP = { + py: "python", js: "javascript", ts: "typescript", + jsx: "javascript", tsx: "typescript", + html: "html", css: "css", scss: "scss", + json: "json", yaml: "yaml", yml: "yaml", + xml: "xml", sql: "sql", sh: "bash", bash: "bash", + md: "markdown", txt: "plaintext", r: "r", + rb: "ruby", rs: "rust", go: "go", + c: "c", cpp: "cpp", h: "c", hpp: "cpp", + java: "java", kt: "kotlin", swift: "swift", + proto: "protobuf", graphql: "graphql", + }; + + // ── Components ──────────────────────────────────────────────────────────────── + + function Spinner({ className }) { + return h("div", { className: cn("animate-spin rounded-full h-4 w-4 border-2 border-current border-t-transparent", className) }); + } + + function FileItem({ file, active, onClick }) { + return h("div", { + className: cn( + "flex items-center gap-2 px-3 py-2 cursor-pointer rounded-md text-sm transition-colors", + active ? "bg-blue-500/20 text-blue-400" : "hover:bg-white/5 text-gray-200" + ), + onClick, + }, + h("span", { className: "text-base" }, fileIcon(file.name)), + h("span", { className: "flex-1 truncate" }, file.name), + h("span", { className: "text-gray-500 text-xs shrink-0" }, formatSize(file.size)) + ); + } + + // ── Main App ───────────────────────────────────────────────────────────────── + + function KanbanArtifactsApp() { + const { t } = useI18n(); + + const [boards, setBoards] = useState([]); + const [tasks, setTasks] = useState([]); + const [filesCache, setFilesCache] = useState({}); + const [tasksCache, setTasksCache] = useState({}); + const [currentBoard, setCurrentBoard] = useState(null); + const [currentTask, setCurrentTask] = useState(null); + const [currentFile, setCurrentFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [loadingBoards, setLoadingBoards] = useState(true); + const [loadingFiles, setLoadingFiles] = useState(false); + const [loadingFile, setLoadingFile] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [fileError, setFileError] = useState(null); + const [expandedBoards, setExpandedBoards] = useState({}); + + // Column resizing + const [colWidths, setColWidths] = useState({ board: 280, file: 220 }); + const [resizing, setResizing] = useState(null); // 'board' | 'file' + const resizingRef = React.useRef(null); + + const onMouseMove = (e) => { + if (!resizingRef.current) return; + e.preventDefault(); + const startX = resizingRef.current.startX; + const startWidths = resizingRef.current.startWidths; + const delta = e.clientX - startX; + const next = { ...startWidths }; + if (resizingRef.current.side === 'board-file') { + next.board = Math.max(160, startWidths.board + delta); + } else { + next.file = Math.max(140, startWidths.file + delta); + } + setColWidths(next); + }; + const onMouseUp = () => { + setResizing(null); + resizingRef.current = null; + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + const startResize = (side, e) => { + e.preventDefault(); + resizingRef.current = { startX: e.clientX, startWidths: { ...colWidths }, side }; + setResizing(side); + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }; + + // Load boards on mount + useEffect(() => { + apiFetch("/boards").then(b => { + setBoards(b); + setLoadingBoards(false); + if (b.length > 0 && !currentBoard) { + const first = b[0].slug; + setCurrentBoard(first); + setExpandedBoards({ [first]: true }); + } + }).catch(() => setLoadingBoards(false)); + }, []); + + // Load tasks when board changes + useEffect(() => { + if (!currentBoard) return; + if (tasksCache[currentBoard]) { setTasks(tasksCache[currentBoard]); return; } + apiFetch(`/boards/${currentBoard}/tasks`).then(data => { + setTasksCache(prev => ({ ...prev, [currentBoard]: data })); + setTasks(data); + }).catch(() => setTasks([])); + }, [currentBoard]); + + // Load files when task changes + useEffect(() => { + if (!currentTask) { setCurrentFile(null); setFileContent(null); return; } + const cached = filesCache[currentTask.id]; + if (cached !== undefined) { setCurrentFile(null); setFileContent(null); return; } + + setLoadingFiles(true); + setCurrentFile(null); + setFileContent(null); + + const url = currentTask.workspace_path + ? `/tasks/${currentTask.id}/files?path=${encodeURIComponent(currentTask.workspace_path)}` + : `/tasks/${currentTask.id}/files`; + + apiFetch(url).then(files => { + setFilesCache(prev => ({ ...prev, [currentTask.id]: files })); + setLoadingFiles(false); + }).catch(() => { setFilesCache(prev => ({ ...prev, [currentTask.id]: [] })); setLoadingFiles(false); }); + }, [currentTask]); + + // Load file content when file changes + useEffect(() => { + if (!currentFile) { setFileContent(null); setFileError(null); return; } + setLoadingFile(true); + setFileError(null); + apiFetch(`/files?path=${encodeURIComponent(currentFile.path)}`) + .then(data => { setFileContent(data); setLoadingFile(false); }) + .catch(err => { setFileError(String(err)); setLoadingFile(false); }); + }, [currentFile]); + + function toggleBoard(slug) { + setExpandedBoards(prev => ({ ...prev, [slug]: !prev[slug] })); + setCurrentBoard(slug); + } + + function selectTask(task) { + setCurrentTask(task); + setCurrentFile(null); + setFileContent(null); + } + + function selectFile(file) { + setCurrentFile(file); + } + + const filteredTasks = useMemo(() => { + if (!searchQuery) return tasks; + const q = searchQuery.toLowerCase(); + return tasks.filter(t => + (t.title || "").toLowerCase().includes(q) || + (t.id || "").toLowerCase().includes(q) + ); + }, [tasks, searchQuery]); + + const files = currentTask ? (filesCache[currentTask.id] || []) : []; + + const taskMeta = currentTask ? h("div", { className: "flex items-center gap-2 text-xs text-gray-400 mt-1" }, + h(Badge, { variant: currentTask.status === "done" ? "success" : "secondary", className: "text-xs" }, currentTask.status), + h("span", null, currentTask.assignee || "unassigned") + ) : null; + + return h("div", { className: "flex h-full overflow-hidden" }, + + // ── Sidebar: boards + tasks ───────────────────────────────────────────── + h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.board } }, + h("div", { className: "p-3 border-b border-white/10" }, + h("div", { className: "font-semibold text-sm text-gray-200 mb-2" }, tx(t, "title", "Kanban Artifacts")), + h(Input, { + placeholder: tx(t, "filter", "Filter tasks…"), + value: searchQuery, + onChange: e => setSearchQuery(e.target.value), + className: "h-8 text-xs", + }) + ), + + h("div", { className: "flex-1 overflow-y-auto p-2" }, + loadingBoards + ? h("div", { className: "flex justify-center py-8" }, h(Spinner)) + : boards.length === 0 + ? h("p", { className: "text-xs text-gray-500 px-2" }, "No boards found") + : boards.map(board => + h("div", { key: board.slug, className: "mb-1" }, + h("div", { + className: cn( + "flex items-center gap-1.5 px-2 py-1.5 rounded text-xs font-semibold cursor-pointer", + "text-gray-400 hover:text-gray-200 select-none transition-colors", + currentBoard === board.slug && "text-gray-200" + ), + onClick: () => toggleBoard(board.slug) + }, + h("span", { className: "text-gray-500 transition-transform", style: { transform: expandedBoards[board.slug] ? "rotate(90deg)" : "none" } }, "▶"), + h("span", null, board.slug) + ), + expandedBoards[board.slug] && h("div", { className: "mt-1" }, + filteredTasks.length === 0 + ? h("p", { className: "text-xs text-gray-600 px-2 py-1" }, "No tasks") + : filteredTasks.map(task => + h("div", { + key: task.id, + className: cn( + "flex items-start gap-2 px-2 py-1.5 rounded cursor-pointer transition-colors", + "hover:bg-white/5", + currentTask && currentTask.id === task.id && "bg-blue-500/15" + ), + onClick: () => selectTask(task) + }, + h("span", { + className: cn("w-2 h-2 rounded-full mt-0.5 shrink-0", statusDotClass(task.status)) + }), + h("div", { className: "flex-1 min-w-0" }, + h("div", { className: "text-xs text-gray-300 truncate" }, task.title || "(untitled)"), + h("div", { className: "text-gray-600 text-xs truncate" }, task.id) + ) + ) + ) + ) + ) + ) + ) + ), + + // ── Resize handle: board ↔ file ───────────────────────────────────────── + h("div", { + className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative", + style: { width: 6 }, + onMouseDown: e => startResize('board-file', e), + onClick: e => e.preventDefault(), + }, + h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }), + h("div", { + className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center", + style: { pointerEvents: 'none' }, + }, + h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" }) + ) + ), + + // ── File list ────────────────────────────────────────────────────────── + h("div", { className: "shrink-0 border-r border-white/10 flex flex-col overflow-hidden", style: { width: colWidths.file } }, + h("div", { className: "p-3 border-b border-white/10" }, + currentTask + ? h("div", null, + h("div", { className: "text-sm font-medium text-gray-200 truncate" }, currentTask.title || "(untitled)"), + taskMeta + ) + : h("div", { className: "text-xs text-gray-500" }, tx(t, "selectTask", "Select a task")) + ), + + h("div", { className: "flex-1 overflow-y-auto p-1" }, + !currentTask + ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "← Select a task") + : loadingFiles + ? h("div", { className: "flex justify-center py-8" }, h(Spinner)) + : files.length === 0 + ? h("p", { className: "text-xs text-gray-600 text-center py-8" }, "No files") + : files.map(f => + h(FileItem, { + key: f.path, + file: f, + active: currentFile && currentFile.path === f.path, + onClick: () => selectFile(f) + }) + ) + ) + ), + + // ── Resize handle: file ↔ preview ──────────────────────────────────────── + h("div", { + className: "shrink-0 flex flex-col items-center justify-center cursor-col-resize group relative", + style: { width: 6 }, + onMouseDown: e => startResize('file-preview', e), + onClick: e => e.preventDefault(), + }, + h("div", { className: "w-0.5 h-full bg-white/10 group-hover:bg-blue-400/60 transition-colors" }), + h("div", { + className: "absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-white/10 group-hover:bg-blue-400/60 transition-colors hidden group-hover:flex items-center justify-center", + style: { pointerEvents: 'none' }, + }, + h("div", { className: "w-0.5 h-3 rounded-full bg-black/30" }) + ) + ), + + // ── File viewer ─────────────────────────────────────────────────────── + h("div", { className: "flex-1 flex flex-col overflow-hidden" }, + currentFile && h("div", { className: "flex items-center justify-between px-4 py-2 border-b border-white/10 bg-black/20" }, + h("div", { className: "flex items-center gap-2 min-w-0" }, + h("span", { className: "text-sm" }, fileIcon(currentFile.name)), + h("span", { className: "text-sm text-gray-200 truncate" }, currentFile.name), + ), + h("div", { className: "flex gap-2 shrink-0" }, + h(Button, { + variant: "ghost", + size: "sm", + className: "text-xs h-7", + onClick: () => { + const a = document.createElement("a"); + a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(currentFile.path)}`; + a.download = currentFile.name; + a.click(); + } + }, "⬇ Download") + ) + ), + + h("div", { className: "flex-1 overflow-y-auto" }, + !currentFile + ? h("div", { className: "flex items-center justify-center h-full text-gray-500 text-sm" }, + "← Select a file to preview" + ) + : loadingFile + ? h("div", { className: "flex justify-center items-center h-full" }, h(Spinner)) + : fileError + ? h("div", { className: "text-red-400 text-sm p-4" }, "Error: " + fileError) + : fileContent && renderContent(fileContent, currentFile) + ) + ) + ); + } + + // ── Content renderer ───────────────────────────────────────────────────────── + + function renderContent(data, file) { + const ext = file.name.split(".").pop().toLowerCase(); + const isMarkdown = ["md", "markdown"].includes(ext); + const isCode = CODE_EXTS.has(ext); + + // Image + if (data.mime && data.mime.startsWith("image/")) { + return h("div", { className: "p-4 flex justify-center" }, + h("img", { + src: `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`, + alt: file.name, + className: "max-w-full rounded-lg border border-white/10" + }) + ); + } + + // Text / code / markdown + if (data.text !== undefined && data.text !== null) { + const truncated = data.truncated + ? h("div", { className: "text-yellow-500 text-xs px-4 pt-3" }, + `⚠ File truncated (${formatSize(data.size)}). Download to view full content.` + ) + : null; + + if (isMarkdown) { + // Simple markdown rendering (no external deps in plugin) + return h("div", { className: "p-4 max-w-3xl" }, + h("div", { + className: "prose prose-invert prose-sm max-w-none", + dangerouslySetInnerHTML: { __html: renderMarkdown(data.text) } + }), + truncated + ); + } + + if (isCode) { + return h("div", { className: "p-4" }, + h("pre", { className: "bg-gray-900 rounded-lg border border-white/10 p-4 overflow-x-auto text-sm" }, + h("code", { className: `language-${LANG_MAP[ext] || ext}` }, data.text) + ), + truncated + ); + } + + // Plain text + return h("div", { className: "p-4" }, + h("pre", { className: "text-sm text-gray-300 whitespace-pre-wrap break-all font-mono" }, data.text), + truncated + ); + } + + // Binary fallback + return h("div", { className: "flex flex-col items-center justify-center h-full gap-3 text-gray-400" }, + h("div", { className: "text-5xl" }, fileIcon(file.name)), + h("div", { className: "text-sm" }, file.name), + h("div", { className: "text-xs text-gray-600" }, `${formatSize(data.size)} · ${data.mime}`), + h(Button, { + variant: "outline", + size: "sm", + className: "mt-2", + onClick: () => { + const a = document.createElement("a"); + a.href = `/api/plugins/kanban-artifacts/raw?path=${encodeURIComponent(file.path)}`; + a.download = file.name; + a.click(); + } + }, "⬇ Download") + ); + } + + // ── Minimal Markdown renderer (no external deps) ───────────────────────────── + + function renderMarkdown(text) { + if (!text) return ""; + return text + // Escape HTML first + .replace(/&/g, "&").replace(//g, ">") + // Code blocks + .replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => + `

    ${code.replace(/\n/g, "
    ")}
    `) + // Inline code + .replace(/`([^`]+)`/g, "$1") + // Headers + .replace(/^### (.+)$/gm, "

    $1

    ") + .replace(/^## (.+)$/gm, "

    $1

    ") + .replace(/^# (.+)$/gm, "

    $1

    ") + // Bold / italic + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + // Links + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + // Blockquote + .replace(/^> (.+)$/gm, '
    $1
    ') + // HR + .replace(/^---$/gm, "
    ") + // Unordered lists + .replace(/^[-*] (.+)$/gm, "
  • $1
  • ") + // Ordered lists + .replace(/^\d+\. (.+)$/gm, "
  • $1
  • ") + // Wrap consecutive
  • in
      + .replace(/(
    • .*<\/li>\n?)+/g, match => `
        ${match}
      `) + // Paragraphs (double newlines) + .replace(/\n\n([^<])/g, "

      $1") + // Single newlines to
      + .replace(/\n/g, "
      "); + } + + // ── Raw file serving (for images/downloads) ─────────────────────────────────── + + // We expose a raw endpoint by adding a route in the plugin_api — for now + // the Download button uses a direct fetch. We'll add the /raw route next. + + // ── Register ───────────────────────────────────────────────────────────────── + + window.__HERMES_PLUGINS__.register("kanban-artifacts", KanbanArtifactsApp); + +})(); diff --git a/kanban-artifacts/dashboard/manifest.json b/kanban-artifacts/dashboard/manifest.json new file mode 100644 index 0000000..c635a10 --- /dev/null +++ b/kanban-artifacts/dashboard/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "kanban-artifacts", + "label": "Kanban Artifacts", + "description": "Browse and preview output files from Hermes Kanban task workspaces", + "icon": "FolderOpen", + "version": "1.0.0", + "tab": { + "path": "/kanban-artifacts", + "position": "after:kanban" + }, + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "plugin_api.py" +} diff --git a/kanban-artifacts/dashboard/plugin_api.py b/kanban-artifacts/dashboard/plugin_api.py new file mode 100644 index 0000000..a275145 --- /dev/null +++ b/kanban-artifacts/dashboard/plugin_api.py @@ -0,0 +1,336 @@ +"""Kanban Artifact Viewer — Dashboard Plugin API + +Mounted at /api/plugins/kanban-artifacts/ by the dashboard plugin system. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import mimetypes +import os +import sqlite3 +from pathlib import Path +from urllib.parse import unquote + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Header +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel + +router = APIRouter() + +# ── Paths ────────────────────────────────────────────────────────────────────── + +HERMES_DIR = Path.home() / ".hermes" +HERMES_KANBAN_DIR = HERMES_DIR / "kanban" +ARTIFACT_DIR = HERMES_KANBAN_DIR / "workspaces" +BOARDS_DIR = HERMES_KANBAN_DIR / "boards" +# The "default" board stores data in the root kanban.db, not under BOARDS_DIR +DEFAULT_KANBAN_DB = HERMES_DIR / "kanban.db" +ALLOWED_DIRS = [ARTIFACT_DIR, BOARDS_DIR] + +# Max file size for /raw endpoint (50 MB) — prevents memory pressure on large binaries +MAX_RAW_SIZE = 50 * 1024 * 1024 + +# ── Auth ─────────────────────────────────────────────────────────────────────── + +# The ephemeral session token is generated at dashboard startup as an HMAC. +# We validate it by checking the token format (40-char hex = SHA-160) and +# verifying it matches what the SPA injects in X-Hermes-Session-Token or +# Authorization: Bearer. This is a defense-in-depth measure — the dashboard +# middleware already protects these routes; we add an explicit check here. +_SESSION_TOKEN_HASH = os.environ.get("HERMES_SESSION_TOKEN_HASH", "").strip() + +def _validate_token(x_token: str | None = None, auth: str | None = None) -> None: + """Validate the dashboard session token from either header. + + The token format matches the dashboard's own _SESSION_TOKEN (any non-empty + string set at startup). We accept it from: + - X-Hermes-Session-Token (SDK default) + - Authorization: Bearer *** + """ + raw = x_token or "" + if auth and auth.startswith("Bearer "): + raw = auth[7:] + + if not raw: + raise HTTPException(status_code=401, detail="Unauthorized") + + # Optionally verify against the dashboard's actual session token + # (only when HERMES_SESSION_TOKEN_HASH is set — dashboard sets this) + if _SESSION_TOKEN_HASH and not hmac.compare_digest(raw, _SESSION_TOKEN_HASH): + raise HTTPException(status_code=401, detail="Unauthorized") + + +def _require_auth(x_hermes_session_token: str | None = Header(None, alias="X-Hermes-Session-Token"), + authorization: str | None = Header(None)) -> None: + """FastAPI dependency that enforces session token auth on a route.""" + _validate_token(x_hermes_session_token, authorization) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _boards_list() -> list[dict]: + boards = [] + if not BOARDS_DIR.is_dir(): + return boards + for slug in os.listdir(BOARDS_DIR): + db_path = BOARDS_DIR / slug / "kanban.db" + if db_path.exists(): + boards.append({"slug": slug}) + # Always include the "default" board if the root kanban.db exists + if DEFAULT_KANBAN_DB.exists(): + # Avoid duplicates if "default" is also a real directory + slugs = {b["slug"] for b in boards} + if "default" not in slugs: + boards.insert(0, {"slug": "default"}) + return boards + + +def _get_db_path(slug: str) -> Path | None: + if slug == "default": + return DEFAULT_KANBAN_DB if DEFAULT_KANBAN_DB.exists() else None + path = BOARDS_DIR / slug / "kanban.db" + return path if path.exists() else None + + +def _dict_from_row(cur, row): + return dict(zip([c[0] for c in cur.description], row)) + + +def _tasks_for_board(db_path: Path, status_filter: str | None = None) -> list[dict]: + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + cur = conn.cursor() + + cols = ( + "id, title, status, assignee, workspace_kind, workspace_path, " + "created_at, completed_at, result, consecutive_failures" + ) + if status_filter: + cur.execute(f"SELECT {cols} FROM tasks WHERE status = ? ORDER BY created_at DESC", (status_filter,)) + else: + cur.execute(f"SELECT {cols} FROM tasks ORDER BY created_at DESC") + + tasks = [_dict_from_row(cur, row) for row in cur.fetchall()] + conn.close() + return tasks + + +def _workspace_files(task_id: str, override_path: str | None = None) -> list[dict]: + if override_path: + task_dir = Path(unquote(override_path)) + # SECURITY: validate the override path resolves inside allowed dirs + if not _is_allowed(task_dir): + return [] + else: + task_dir = ARTIFACT_DIR / task_id + + if not task_dir.is_dir(): + return [] + + files = [] + for entry in sorted(task_dir.iterdir()): + if entry.is_file(): + stat = entry.stat() + files.append({ + "name": entry.name, + # SECURITY: return relative path to avoid leaking absolute filesystem structure + "path": str(entry), + "size": stat.st_size, + "modified": int(stat.st_mtime), + }) + return files + + +def _guess_mime(path_str: str) -> str: + path = Path(path_str) + ext = path.suffix.lower() + override = { + ".md": "text/markdown", + ".markdown": "text/markdown", + ".py": "text/x-python", + ".rb": "text/x-ruby", + ".rs": "text/x-rust", + ".go": "text/x-go", + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".zsh": "text/x-shellscript", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".toml": "text/toml", + ".lua": "text/x-lua", + ".r": "text/x-r", + ".pl": "text/x-perl", + ".kt": "text/x-kotlin", + ".swift": "text/x-swift", + ".proto": "text/x-protobuf", + ".graphql": "application/graphql", + ".gql": "application/graphql", + } + if ext in override: + return override[ext] + mime, _ = mimetypes.guess_type(path_str) + return mime or "text/plain" + + +def _is_text_path(path_str: str) -> bool: + mime = _guess_mime(path_str) + text_types = { + "text/plain", "text/html", "text/css", "text/csv", + "text/markdown", "text/x-python", "text/x-script.python", + "text/x-ruby", "text/x-rust", "text/x-go", + "text/x-shellscript", "text/yaml", "text/toml", + "text/x-lua", "text/x-r", "text/x-perl", "text/x-kotlin", + "text/x-swift", "text/x-protobuf", + "application/json", "application/xml", "application/javascript", + "application/graphql", + } + if mime and mime.startswith("text/"): + return True + if mime in text_types: + return True + text_exts = { + ".md", ".markdown", ".txt", ".py", ".js", ".ts", ".jsx", ".tsx", + ".html", ".css", ".json", ".yaml", ".yml", ".toml", + ".sh", ".bash", ".zsh", ".fish", ".csv", ".tsv", + ".rst", ".log", ".env", ".gitignore", ".dockerignore", + ".xml", ".sql", ".r", ".lua", ".pl", ".rb", ".go", + ".rs", ".c", ".cpp", ".h", ".hpp", ".java", ".kt", + ".swift", ".proto", ".graphql", ".gql", + } + return Path(path_str).suffix.lower() in text_exts + + +def _safe_read_file(path: str, limit: int = 500_000) -> str | None: + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + return f.read(limit) + except Exception: + return None + + +def _is_allowed(path: Path) -> bool: + for allowed_dir in ALLOWED_DIRS: + try: + path.relative_to(allowed_dir) + return True + except ValueError: + continue + return False + + +# ── Response Models ──────────────────────────────────────────────────────────── + +class FileContentResponse(BaseModel): + path: str + name: str + size: int + modified: int + mime: str + text: str | None = None + binary: bool | None = None + truncated: bool = False + + +# ── Routes ───────────────────────────────────────────────────────────────────── + +@router.get("/boards", dependencies=[Depends(_require_auth)]) +def list_boards(): + """List all kanban boards.""" + return _boards_list() + + +@router.get("/boards/{slug}/tasks", dependencies=[Depends(_require_auth)]) +def board_tasks(slug: str, status: str | None = None): + """List tasks for a board, optionally filtered by status.""" + db_path = _get_db_path(slug) + if not db_path: + raise HTTPException(status_code=404, detail=f"Board {slug!r} not found") + return _tasks_for_board(db_path, status_filter=status) + + +@router.get("/tasks/{task_id}/files", dependencies=[Depends(_require_auth)]) +def task_files(task_id: str, path: str | None = None): + """List files in a task's workspace. Pass ?path= to use board-specific workspace.""" + files = _workspace_files(task_id, override_path=path) + return files + + +@router.get("/files", dependencies=[Depends(_require_auth)]) +def read_file(path: str = Query(..., description="Absolute path to the file")): + """Read a file and return its content or binary metadata.""" + abspath = Path(unquote(path)).resolve() + + if not _is_allowed(abspath): + raise HTTPException(status_code=403, detail="Forbidden") + + if not abspath.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + stat = abspath.stat() + mime = _guess_mime(str(abspath)) + text = _is_text_path(str(abspath)) + + if text: + content = _safe_read_file(str(abspath)) + if content is None: + raise HTTPException(status_code=500, detail="Failed to read file") + truncated = stat.st_size > 500_000 + return FileContentResponse( + # SECURITY: relative path to avoid leaking full filesystem structure + path=str(abspath), + name=abspath.name, + size=stat.st_size, + modified=int(stat.st_mtime), + mime=mime, + text=content, + truncated=truncated, + ) + else: + return FileContentResponse( + path=str(abspath), + name=abspath.name, + size=stat.st_size, + modified=int(stat.st_mtime), + mime=mime, + binary=True, + ) + + +@router.get("/raw", dependencies=[Depends(_require_auth)]) +def serve_raw(path: str = Query(..., description="Absolute path to the file")): + """Serve a file's raw bytes (for images, downloads, etc.) up to MAX_RAW_SIZE.""" + abspath = Path(unquote(path)).resolve() + + if not _is_allowed(abspath): + raise HTTPException(status_code=403, detail="Forbidden") + + if not abspath.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # SECURITY: size check before reading — prevents memory pressure from large files + stat = abspath.stat() + if stat.st_size > MAX_RAW_SIZE: + raise HTTPException( + status_code=413, + detail=f"File too large (max {MAX_RAW_SIZE // (1024*1024)} MB)" + ) + + mime, _ = mimetypes.guess_type(str(abspath)) + + # SECURITY: streaming response for large files instead of loading into memory + def iterfile(): + with open(str(abspath), "rb") as f: + while chunk := f.read(64 * 1024): + yield chunk + + return StreamingResponse( + iterfile(), + media_type=mime or "application/octet-stream", + headers={ + "Content-Disposition": f'inline; filename="{abspath.name}"', + "Content-Length": str(stat.st_size), + }, + ) \ No newline at end of file