Files
enkly-ai/services/gateway/internal/handlers/attachments.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

482 lines
18 KiB
Go

// Package handlers (attachments.go) adds the chat composer's "Add
// files or photos" attachment flow — strongly inspired by Claude
// Code's chat input: drag a file (or a screenshot) onto the message
// box, or click the paperclip, and it shows up as a chip with a
// thumbnail while it uploads in the background; remove it before
// sending if it was a mistake. A .zip is handled specially (see
// "Zip extraction" below): instead of landing as one opaque binary
// blob the agent's tools can't do anything useful with, it's unpacked
// into real workspace files the agent can read_file, grep, and
// list_directory exactly like any other code it's handed — no new
// agent tool needed, "read and review" falls out of tools that
// already exist.
//
// Wire format: web/static/js/app.js uploads each staged file the
// moment it's picked/dropped/pasted, one at a time, to POST
// /files/attach (AttachFile below). That handler is a thin wrapper
// around files.go's writeUploadedFile — the same multipart-part ->
// Workspace.UploadFile path the sidebar's bulk upload form already
// uses — except it (a) namespaces attachments under attachments/ so
// they don't clutter the workspace root the agent writes code into,
// (b) de-dupes the filename against what's already there (see
// uniqueWorkspacePath; UploadFile itself silently overwrites), and
// (c) answers with JSON instead of an HTML fragment, since each
// chip's uploading/done/error state is owned by the browser, not the
// server.
//
// Storage: conversation.Message (api/proto/conversation/v1) has no
// field for "files attached to this message" — adding one would be a
// schema change across a service boundary well beyond this task's
// scope. Instead, StartTurn's prompt argument IS what gets persisted
// as the message's content (see agent's StartTurn and
// internal/application/service.go), so the uploaded paths ride along
// as a trailing, both human- and machine-readable block:
//
// <what the user typed>
//
// [Attached files]
// - attachments/diagram.png
// - attachments/project (12 files)
//
// appendAttachmentsBlock writes it, splitAttachments reads it back.
// The model sees plain, readable text it can act on (e.g. read_file
// one of the paths, or list_directory an extracted folder);
// renderUserMessage parses the same string back into prompt text +
// attachment chips for both the just-sent bubble (chat.go's Chat) and
// every past message on reload (pages.go's prepareMessages) — one
// parser, two call sites, same as how deliverables.go's producedPath
// is shared across a live stream and history. See ARCHITECTURE.md's
// "known gaps" for the same trade-off made elsewhere in this codebase.
package handlers
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/renderkit"
)
const (
// attachmentsDir keeps chat attachments out of the workspace root,
// where the agent reads/writes/greps code — see package doc.
attachmentsDir = "attachments"
// attachmentFormField is both the name of the hidden <input> the
// composer form adds per uploaded attachment (app.js) and the
// multipart field AttachFile reads a single file from.
attachmentFormField = "attachments"
// maxChatAttachments mirrors maxUploadFilesPerRequest: a sanity
// cap on how many files (or zips) one message can carry, enforced
// server-side regardless of what the client already limits to.
// A single extracted zip still counts as exactly one of these,
// however many files end up inside it.
maxChatAttachments = 8
)
var imageExts = map[string]bool{
".png": true, ".jpg": true, ".jpeg": true, ".gif": true,
".webp": true, ".svg": true, ".bmp": true, ".avif": true,
}
func isImagePath(p string) bool { return imageExts[strings.ToLower(path.Ext(p))] }
// ---------------------------------------------------------------------
// POST /files/attach — one file per request, called by app.js the
// moment a file is staged (picked, dropped, or pasted).
// ---------------------------------------------------------------------
type attachFileResult struct {
Path string `json:"path"`
Name string `json:"name"`
IsImage bool `json:"isImage"`
IsArchive bool `json:"isArchive"` // true: Path is a folder of extracted files, not one file
EntryCount int `json:"entryCount,omitempty"` // set when IsArchive
DownloadURL string `json:"downloadUrl,omitempty"` // empty for an archive — there's no single file to download
SizeHuman string `json:"sizeHuman"`
}
func (h *Handlers) AttachFile(w http.ResponseWriter, r *http.Request) {
var headers []*multipart.FileHeader
if r.MultipartForm != nil {
headers = r.MultipartForm.File[uploadFormField]
}
if len(headers) != 1 {
writeJSONError(w, http.StatusBadRequest, "attach one file per request")
return
}
fh := headers[0]
if isZipPath(fh.Filename) {
result, err := h.extractZipAttachment(r, fh)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
return
}
rel, err := h.saveChatAttachment(r, fh)
if err != nil {
writeJSONError(w, http.StatusBadRequest, err.Error())
return
}
var size int64
if stat, err := h.Workspace.StatFile(r.Context(), &workspacev1.StatFileRequest{Path: rel}); err == nil {
size = stat.GetSize()
}
writeJSON(w, http.StatusOK, attachFileResult{
Path: rel, Name: path.Base(rel), IsImage: isImagePath(rel),
DownloadURL: downloadURLFor(rel), SizeHuman: humanBytes(size),
})
}
func (h *Handlers) saveChatAttachment(r *http.Request, fh *multipart.FileHeader) (string, error) {
base, err := uploadDestPath("", fh.Filename)
if err != nil {
return "", err
}
rel, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, base))
if err != nil {
return "", err
}
return h.writeUploadedFile(r, rel, fh)
}
// uniqueWorkspacePath appends " (2)", " (3)", … before the extension
// until StatFile reports nothing at that path — the way a desktop
// file manager avoids clobbering an existing file. Needed because
// workspace's UploadFile writes with os.WriteFile, which truncates in
// place: two attachments named the same thing in one conversation
// would otherwise silently overwrite each other. Works equally for a
// plain file's path and an extraction folder's path — StatFile
// reports IsDir either way, and all this cares about is Exists.
func (h *Handlers) uniqueWorkspacePath(ctx context.Context, rel string) (string, error) {
ext := path.Ext(rel)
base := strings.TrimSuffix(rel, ext)
for n := 1; n <= 1000; n++ {
candidate := rel
if n > 1 {
candidate = fmt.Sprintf("%s (%d)%s", base, n, ext)
}
stat, err := h.Workspace.StatFile(ctx, &workspacev1.StatFileRequest{Path: candidate})
if err != nil {
// Can't check — don't block the upload over it, just take
// the first candidate; UploadFile will still succeed.
return candidate, nil
}
if !stat.GetExists() {
return candidate, nil
}
}
return rel, nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------
// Zip extraction. A .zip attached to the chat is unpacked into its
// own folder under attachments/ — e.g. attachments/project/ — rather
// than stored as a single binary blob, so the agent's existing
// read_file / list_directory / glob_search / grep tools (all already
// scoped to the whole workspace) can actually read and review what's
// inside it. Nothing about those tools changes; extraction is the
// entire feature.
// ---------------------------------------------------------------------
const (
// maxZipEntries mirrors workspace's own maxWorkspaceFilesListed —
// the same "a listing/extraction this big is almost certainly not
// something a person meant to attach to a chat message" cap.
maxZipEntries = 500
// maxZipUncompressedBytes bounds the classic zip-bomb case: the
// compressed upload itself is already capped at maxUploadFileBytes
// (20 MiB) by the ordinary multipart size limit, but a small zip
// can still decompress to something enormous. 200 MiB total is
// generous for a real codebase or document set while still being
// a hard, enforced ceiling.
maxZipUncompressedBytes = 200 << 20
)
// isZipPath checks the filename's extension only — deliberately not
// the multipart part's declared Content-Type, which the browser sets
// from that same extension anyway and is trivial to get wrong or omit.
func isZipPath(name string) bool {
return strings.EqualFold(path.Ext(filepath.Base(filepath.ToSlash(name))), ".zip")
}
func (h *Handlers) extractZipAttachment(r *http.Request, fh *multipart.FileHeader) (attachFileResult, error) {
if fh.Size > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q is %s, larger than the %s zip-upload limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes))
}
src, err := fh.Open()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", fh.Filename, err)
}
defer src.Close()
raw, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1))
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: read: %w", fh.Filename, err)
}
if len(raw) > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q exceeds the %s zip-upload limit", fh.Filename, humanBytes(maxUploadFileBytes))
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return attachFileResult{}, fmt.Errorf("%q doesn't look like a valid zip file", fh.Filename)
}
if len(zr.File) > maxZipEntries {
return attachFileResult{}, fmt.Errorf("%q has too many files to attach (limit %d)", fh.Filename, maxZipEntries)
}
name := strings.TrimSuffix(path.Base(filepath.ToSlash(fh.Filename)), ".zip")
if name == "" {
name = "archive"
}
destDir, err := h.uniqueWorkspacePath(r.Context(), path.Join(attachmentsDir, name))
if err != nil {
return attachFileResult{}, err
}
var totalUncompressed int64
written := 0
for _, zf := range zr.File {
if zf.FileInfo().IsDir() {
continue
}
entry := sanitizeZipEntryName(zf.Name)
if entry == "" {
continue // path-traversal / absolute / empty entry — skip rather than fail the whole zip
}
if zf.UncompressedSize64 > uint64(maxUploadFileBytes) {
return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes))
}
totalUncompressed += int64(zf.UncompressedSize64)
if totalUncompressed > maxZipUncompressedBytes {
return attachFileResult{}, fmt.Errorf("%q unpacks to more than %s — too large to attach", fh.Filename, humanBytes(maxZipUncompressedBytes))
}
rc, err := zf.Open()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", zf.Name, err)
}
content, err := io.ReadAll(io.LimitReader(rc, maxUploadFileBytes+1))
rc.Close()
if err != nil {
return attachFileResult{}, fmt.Errorf("%q: read: %w", zf.Name, err)
}
if int64(len(content)) > maxUploadFileBytes {
return attachFileResult{}, fmt.Errorf("%q inside %q is larger than the %s per-file limit", zf.Name, fh.Filename, humanBytes(maxUploadFileBytes))
}
if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{
Path: path.Join(destDir, entry), Content: content,
}); err != nil {
return attachFileResult{}, fmt.Errorf("%q: %w", entry, err)
}
written++
}
if written == 0 {
return attachFileResult{}, fmt.Errorf("%q doesn't have anything extractable in it", fh.Filename)
}
return attachFileResult{
Path: destDir, Name: path.Base(fh.Filename), IsArchive: true,
EntryCount: written, SizeHuman: humanBytes(totalUncompressed),
}, nil
}
// sanitizeZipEntryName is the standard "zip slip" guard: a malicious
// entry name like "../../somewhere/else" or "/etc/passwd" must never
// be allowed to land outside the extraction folder. Workspace's own
// Toolbox.ResolvePath (services/workspace/internal/infrastructure/
// sandboxfs) independently refuses to write outside the workspace
// root at all — a real second layer, not redundant with this one —
// but that check alone wouldn't stop a crafted entry from escaping
// just the destDir subfolder and overwriting an unrelated file
// elsewhere in the workspace (the agent's own code, say). Returns ""
// for anything that isn't a clean, relative, forward-only path.
func sanitizeZipEntryName(name string) string {
clean := path.Clean(strings.TrimPrefix(filepath.ToSlash(name), "/"))
if clean == "" || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return ""
}
return clean
}
// ---------------------------------------------------------------------
// Encoding attachments into (and back out of) a message's plain-text
// content — see package doc.
// ---------------------------------------------------------------------
// attachmentRef is one "[Attached files]" bullet: a plain file
// (EntryCount 0) or an extracted zip's folder (EntryCount = how many
// files it unpacked into).
type attachmentRef struct {
Path string
EntryCount int
}
const attachmentsBlockHeader = "\n\n[Attached files]\n"
// attachmentsBlockRe matches appendAttachmentsBlock's own output: a
// "[Attached files]" heading followed by one "- path" (or
// "- path (N files)") bullet per line, anchored to the end of the
// string so it only ever strips a block this package wrote, never
// something a user happened to type.
var attachmentsBlockRe = regexp.MustCompile(`(?s)\n\n\[Attached files\]\n((?:- .+\n?)+)$`)
// zipBulletRe recognizes appendAttachmentsBlock's "N files" suffix on
// an extracted-archive bullet; anything that doesn't match it is a
// plain file path.
var zipBulletRe = regexp.MustCompile(`^(.+) \((\d+) files?\)$`)
// appendAttachmentsBlock replaces chat.go's old bare
// `renderkit.PlainStream(prompt)` call site's input: the exact string
// passed to Agent.StartTurn (and therefore persisted verbatim as the
// message's content).
func appendAttachmentsBlock(prompt string, refs []attachmentRef) string {
if len(refs) == 0 {
return prompt
}
var b strings.Builder
b.WriteString(prompt)
b.WriteString(attachmentsBlockHeader)
for _, ref := range refs {
b.WriteString("- ")
b.WriteString(ref.Path)
if ref.EntryCount > 0 {
b.WriteString(fmt.Sprintf(" (%d file", ref.EntryCount))
if ref.EntryCount != 1 {
b.WriteString("s")
}
b.WriteString(")")
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
// splitAttachments is appendAttachmentsBlock's inverse.
func splitAttachments(content string) (text string, refs []attachmentRef) {
loc := attachmentsBlockRe.FindStringSubmatchIndex(content)
if loc == nil {
return content, nil
}
text = content[:loc[0]]
for _, line := range strings.Split(strings.TrimRight(content[loc[2]:loc[3]], "\n"), "\n") {
p := strings.TrimPrefix(line, "- ")
if p == "" {
continue
}
if m := zipBulletRe.FindStringSubmatch(p); m != nil {
n, _ := strconv.Atoi(m[2])
refs = append(refs, attachmentRef{Path: m[1], EntryCount: n})
continue
}
refs = append(refs, attachmentRef{Path: p})
}
return text, refs
}
// cleanAttachmentPaths sanitizes the composer form's "attachments"
// values (one hidden <input> per uploaded file or extracted archive —
// see app.js's addHiddenInput, which encodes an archive as
// "path|entryCount"): resolve away any "." / ".." games in the path,
// drop duplicates, cap the count. The workspace service's own jail
// (sandboxfs.Toolbox.ResolvePath) is still the real security boundary
// for anything that later reads these paths; this is just keeping the
// stored/displayed list sane.
func cleanAttachmentPaths(raw []string) []attachmentRef {
seen := make(map[string]bool, len(raw))
out := make([]attachmentRef, 0, len(raw))
for _, v := range raw {
p, count := v, 0
if i := strings.LastIndex(v, "|"); i != -1 {
if n, err := strconv.Atoi(v[i+1:]); err == nil && n > 0 {
p, count = v[:i], n
}
}
clean := strings.TrimPrefix(path.Clean("/"+p), "/")
if clean == "" || clean == "." || seen[clean] {
continue
}
seen[clean] = true
out = append(out, attachmentRef{Path: clean, EntryCount: count})
if len(out) == maxChatAttachments {
break
}
}
return out
}
// defaultAttachmentPrompt replaces chat.go's Chat validating an empty
// prompt the way the agent service always has (service.go's StartTurn
// rejects "" with "Type a message first.") — if files were attached
// with no text at all, that empty-prompt rule shouldn't block the
// send; give it a minimal prompt instead, same spirit as app.js's
// existing "I uploaded X." auto-fill for the sidebar upload flow.
func defaultAttachmentPrompt(refs []attachmentRef) string {
if len(refs) == 1 {
return "Take a look at the attached file."
}
return "Take a look at the attached files."
}
// ---------------------------------------------------------------------
// Rendering a user message's content (prompt text + attachment
// chips) — shared by chat.go (the just-sent bubble) and pages.go
// (every past message), replacing their old direct
// renderkit.PlainStream(content) calls.
// ---------------------------------------------------------------------
type attachmentChipView struct {
Name string
IsImage bool
IsArchive bool
EntryCount int
DownloadURL string // empty for an archive chip — nothing to download as one file
}
func (h *Handlers) renderUserMessage(content string) template.HTML {
text, refs := splitAttachments(content)
html := renderkit.PlainStream(text)
if len(refs) > 0 {
views := make([]attachmentChipView, len(refs))
for i, ref := range refs {
if ref.EntryCount > 0 {
views[i] = attachmentChipView{Name: path.Base(ref.Path), IsArchive: true, EntryCount: ref.EntryCount}
continue
}
views[i] = attachmentChipView{Name: path.Base(ref.Path), IsImage: isImagePath(ref.Path), DownloadURL: downloadURLFor(ref.Path)}
}
html += h.renderString("message_attachments", views)
}
return template.HTML(html) // #nosec G203 -- renderkit.PlainStream escapes the text; message_attachments is a trusted template
}