feat(kanban): file attachments on tasks (#35395)

Tasks can now carry file attachments (PDFs, images, source docs) that
workers read directly — closes the gap where source material had to be
pasted as a path into the task body.

- kanban_db: task_attachments table (additive), Attachment dataclass,
  add/list/get/delete accessors, attachments_root/task_attachments_dir
  path helpers (per-board, HERMES_KANBAN_ATTACHMENTS_ROOT override)
- build_worker_context: surfaces each attachment's absolute path so the
  worker (full file/terminal tool access) reads it via read_file/pdftotext
- dashboard API: POST/GET/DELETE attachment routes (multipart upload,
  25MB cap, traversal-safe filenames, root-containment check on download)
- dashboard UI: Attachments section in the task drawer — upload button,
  list with download, per-row remove
- docs + tests (13 cases: DB accessors, REST round-trip, traversal
  rejection, collision suffixing, worker-context surfacing)

Closes #35338
This commit is contained in:
Teknium
2026-05-30 07:41:04 -07:00
committed by GitHub
parent 20d073fd0b
commit b47cb1bbf2
6 changed files with 884 additions and 1 deletions

View File

@ -2741,6 +2741,8 @@
// Ready/Block/Complete buttons feel like no-ops. See #26744.
const [patchErr, setPatchErr] = useState(null);
const [newComment, setNewComment] = useState("");
const [uploadBusy, setUploadBusy] = useState(false);
const [uploadErr, setUploadErr] = useState(null);
const [editing, setEditing] = useState(false);
// Home-channel notification toggles. homeChannels is the list of platforms
// the user has a /sethome on; each entry has a `subscribed` bool telling
@ -2789,6 +2791,49 @@
}).catch(function (e) { setErr(String(e.message || e)); });
};
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
// so the browser sets the multipart boundary. Auth rides the session
// cookie + bearer token, matching the rest of the dashboard.
const handleUpload = function (fileList) {
const files = Array.prototype.slice.call(fileList || []);
if (!files.length) return;
setUploadBusy(true);
setUploadErr(null);
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
// Upload sequentially so a partial failure leaves a clear state.
let chain = Promise.resolve();
files.forEach(function (f) {
chain = chain.then(function () {
const fd = new FormData();
fd.append("file", f, f.name);
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
});
});
});
chain.then(function () {
load();
props.onRefresh();
}).catch(function (e) {
setUploadErr(String(e.message || e));
}).finally(function () {
setUploadBusy(false);
});
};
const handleDeleteAttachment = function (attachmentId) {
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
.then(function () { load(); props.onRefresh(); })
.catch(function (e) { setUploadErr(String(e.message || e)); });
};
const doPatch = function (patch, opts) {
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
return Promise.resolve();
@ -2946,6 +2991,10 @@
homeBusy: homeBusy,
onToggleHomeSub: toggleHomeSubscription,
onRefresh: props.onRefresh,
onUpload: handleUpload,
onDeleteAttachment: handleDeleteAttachment,
uploadBusy: uploadBusy,
uploadErr: uploadErr,
}) : null,
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
h(Input, {
@ -2968,11 +3017,118 @@
);
}
function _fmtBytes(n) {
n = Number(n) || 0;
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
return (n / (1024 * 1024)).toFixed(1) + " MB";
}
// Attachments section in the task drawer (#35338). Upload button +
// list with download links and a delete (×) per row. The download
// link hits GET /attachments/:id which streams the file; the worker
// context surfaces the same files' absolute paths so a kanban worker
// can read them with the file/terminal tools.
function AttachmentsSection(props) {
const i18n = props.i18n;
const atts = props.attachments || [];
const fileRef = useRef(null);
const [dlErr, setDlErr] = useState(null);
// Download via authenticated fetch → blob → synthetic anchor click.
// A plain <a href> can't carry the session header/bearer the dashboard
// auth middleware requires in loopback mode, so fetch with the token
// and hand the browser a blob URL instead.
function downloadAttachment(a) {
const token = window.__HERMES_SESSION_TOKEN__ || "";
const headers = token ? { Authorization: "Bearer " + token } : {};
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
setDlErr(null);
fetch(url, { headers: headers, credentials: "same-origin" })
.then(function (resp) {
if (!resp.ok) {
return resp.text().then(function (txt) {
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
});
}
return resp.blob();
})
.then(function (blob) {
const objUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objUrl;
link.download = a.filename || "attachment";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
})
.catch(function (e) { setDlErr(String(e.message || e)); });
}
return h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
h("input", {
ref: fileRef,
type: "file",
multiple: true,
style: { display: "none" },
onChange: function (e) {
if (props.onUpload) props.onUpload(e.target.files);
// Reset so selecting the same file again re-triggers onChange.
try { e.target.value = ""; } catch (_e) { /* ignore */ }
},
}),
h("div", { className: "flex items-center gap-2 mb-2" },
h(Button, {
size: "sm",
variant: "outline",
disabled: !!props.uploadBusy,
onClick: function () { if (fileRef.current) fileRef.current.click(); },
}, props.uploadBusy
? tx(i18n, "uploading", "Uploading…")
: tx(i18n, "uploadFile", "Upload file")),
),
(props.uploadErr || dlErr)
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
: null,
atts.length === 0
? h("div", { className: "text-xs text-muted-foreground" },
tx(i18n, "noAttachments", "— no attachments —"))
: atts.map(function (a) {
return h("div", {
key: a.id,
className: "flex items-center justify-between gap-2 py-1 text-sm",
},
h("button", {
type: "button",
className: "hermes-kanban-attachment-link truncate",
title: a.filename,
onClick: function () { downloadAttachment(a); },
}, a.filename),
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
_fmtBytes(a.size)),
h("button", {
type: "button",
className: "hermes-kanban-drawer-close",
title: tx(i18n, "removeAttachment", "Remove attachment"),
onClick: function () {
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
"Remove this attachment?"))) {
if (props.onDelete) props.onDelete(a.id);
}
},
}, "×"),
);
}),
);
}
function TaskDetail(props) {
const { t: i18n } = useI18n();
const t = props.data.task;
const comments = props.data.comments || [];
const events = props.data.events || [];
const attachments = props.data.attachments || [];
const links = props.data.links || { parents: [], children: [] };
return h("div", { className: "hermes-kanban-drawer-body" },
@ -3042,6 +3198,15 @@
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
) : null,
h(AttachmentsSection, {
attachments: attachments,
boardSlug: props.boardSlug,
onUpload: props.onUpload,
onDelete: props.onDeleteAttachment,
uploadBusy: props.uploadBusy,
uploadErr: props.uploadErr,
i18n: i18n,
}),
h("div", { className: "hermes-kanban-section" },
h("div", { className: "hermes-kanban-section-head" },
`${tx(i18n, "comments", "Comments")} (${comments.length})`),

View File

@ -386,6 +386,25 @@
}
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
/* Attachment download trigger — styled as a link, rendered as a <button>
so the click handler can fetch with the session token (#35338). */
.hermes-kanban-attachment-link {
appearance: none;
background: transparent;
border: 0;
padding: 0;
margin: 0;
text-align: left;
color: var(--color-primary, #6ea8fe);
cursor: pointer;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
.hermes-kanban-drawer-body {
flex: 1;
overflow-y: auto;

View File

@ -43,9 +43,11 @@ import os
import sqlite3
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from hermes_cli import kanban_db
@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
}
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
"""Serialise an Attachment for the drawer. ``stored_path`` is the
absolute on-disk path workers read; the UI uses ``id`` for download."""
return {
"id": a.id,
"task_id": a.task_id,
"filename": a.filename,
"content_type": a.content_type,
"size": a.size,
"uploaded_by": a.uploaded_by,
"stored_path": a.stored_path,
"created_at": a.created_at,
}
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
"""Serialise a Run for the drawer's Run history section."""
return {
@ -531,6 +548,7 @@ def get_task(
"task": task_d,
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
"links": _links_for(conn, task_id),
"runs": [
_run_dict(r)
@ -609,6 +627,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
conn.close()
# ---------------------------------------------------------------------------
# Attachments — upload / list / download / delete (#35338)
# ---------------------------------------------------------------------------
# Cap a single upload so a runaway request can't fill the disk. 25 MB
# comfortably covers PDFs, images, and source docs — the kanban use case.
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
def _safe_attachment_name(raw: str) -> str:
"""Reduce a client-supplied filename to a safe basename.
Strips any directory components (``os.path.basename`` on both
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
to its leaf. Rejects empty / dotfile-only names. The result is only
ever joined under the per-task attachments dir, never used verbatim
as a path from the client.
"""
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
# Drop control chars and leading dots so we never write a dotfile or
# a name with embedded NULs/newlines.
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
name = name.lstrip(".").strip()
if not name:
raise HTTPException(status_code=400, detail="invalid attachment filename")
return name[:200]
@router.get("/tasks/{task_id}/attachments")
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
return {
"attachments": [
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
]
}
finally:
conn.close()
@router.post("/tasks/{task_id}/attachments")
async def upload_task_attachment(
task_id: str,
file: UploadFile = File(...),
board: Optional[str] = Query(None),
uploaded_by: Optional[str] = Form(None),
):
"""Store an uploaded file for a task and record its metadata.
The blob lands under ``attachments_root(board)/<task_id>/`` with a
sanitised, collision-resolved name. The worker reads it via the
absolute path surfaced in ``build_worker_context``.
"""
board = _resolve_board(board)
conn = _conn(board=board)
try:
if kanban_db.get_task(conn, task_id) is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
safe_name = _safe_attachment_name(file.filename or "")
# Stream to disk with a hard size cap so a huge upload can't fill
# the disk. Read in chunks; abort + clean up if the cap is hit.
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
dest_dir.mkdir(parents=True, exist_ok=True)
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
stem, dot, ext = safe_name.partition(".")
candidate = safe_name
n = 1
while (dest_dir / candidate).exists():
candidate = f"{stem} ({n}){dot}{ext}"
n += 1
dest_path = dest_dir / candidate
total = 0
try:
with open(dest_path, "wb") as out:
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > _MAX_ATTACHMENT_BYTES:
out.close()
dest_path.unlink(missing_ok=True)
raise HTTPException(
status_code=413,
detail=(
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
),
)
out.write(chunk)
except HTTPException:
raise
except OSError as exc:
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
att_id = kanban_db.add_attachment(
conn,
task_id,
filename=candidate,
stored_path=str(dest_path.resolve()),
content_type=file.content_type,
size=total,
uploaded_by=(uploaded_by or "dashboard"),
)
att = kanban_db.get_attachment(conn, att_id)
return {"attachment": _attachment_dict(att) if att else None}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
finally:
conn.close()
@router.get("/attachments/{attachment_id}")
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.get_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
# Confirm the blob still lives under the board's attachments root
# before serving — defense in depth against a tampered DB row.
root = kanban_db.attachments_root(board=board).resolve()
try:
stored = Path(att.stored_path).resolve()
stored.relative_to(root)
except (ValueError, OSError):
raise HTTPException(status_code=404, detail="attachment file unavailable")
if not stored.is_file():
raise HTTPException(status_code=404, detail="attachment file missing on disk")
return FileResponse(
path=str(stored),
filename=att.filename,
media_type=att.content_type or "application/octet-stream",
)
finally:
conn.close()
@router.delete("/attachments/{attachment_id}")
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
board = _resolve_board(board)
conn = _conn(board=board)
try:
att = kanban_db.delete_attachment(conn, attachment_id)
if att is None:
raise HTTPException(status_code=404, detail="attachment not found")
return {"ok": True, "id": attachment_id}
finally:
conn.close()
# ---------------------------------------------------------------------------
# PATCH /tasks/:id (status / assignee / priority / title / body)
# ---------------------------------------------------------------------------