482 lines
18 KiB
Go
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
|
|
}
|