Files
enkly-ai/services/gateway/internal/handlers/files.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

273 lines
9.6 KiB
Go

// Package handlers (files.go) replaces internal/handlers/files.go.
// Every path that used to go through llm.Toolbox.ResolvePath on the
// local filesystem now goes through the workspace service's gRPC API
// instead (UploadFile/DownloadFile/Tree) — that service applies the
// exact same jail (see services/workspace/internal/infrastructure/
// sandboxfs/sandbox.go), just on the other end of the wire now.
package handlers
import (
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"path"
"path/filepath"
"strconv"
"strings"
"time"
workspacev1 "enkly/gen/workspace/v1"
"enkly/pkg/authsupport"
)
// officeContentTypes covers the handful of extensions whose correct
// MIME type Go's own mime.TypeByExtension can't always be trusted to
// know (it falls back to the host OS's registered MIME database,
// which doesn't universally list the OOXML types) — most importantly
// pkg/officekit's own .docx/.xlsx/.pptx output, which would otherwise
// sniff as "application/zip" (see downloadContentType).
var officeContentTypes = map[string]string{
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".pdf": "application/pdf",
}
// downloadContentType returns the Content-Type DownloadFile should
// advertise for name, by extension: officeContentTypes' explicit
// entries first, then mime.TypeByExtension, then a generic binary
// fallback — never server-side content sniffing (see the comment at
// DownloadFile's w.Header().Set("Content-Type", ...) call for why).
func downloadContentType(name string) string {
ext := strings.ToLower(filepath.Ext(name))
if ct, ok := officeContentTypes[ext]; ok {
return ct
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
const (
maxUploadFileBytes = 20 << 20
maxUploadFilesPerRequest = 8
maxUploadRequestBytes = maxUploadFilesPerRequest*maxUploadFileBytes + (1 << 20)
maxUploadMemory = 10 << 20
uploadFormField = "files"
)
type workspaceFileView struct {
Path string
DownloadURL string
Size int64
SizeHuman string
ModTime time.Time
}
type filesPanelData struct {
WorkspaceFiles []workspaceFileView
ConversationID int64
CSRFToken string
UploadMessage string
UploadError string
}
// ---------------------------------------------------------------------
// GET /files, POST /files/upload
// ---------------------------------------------------------------------
func (h *Handlers) FilesPanel(w http.ResponseWriter, r *http.Request) {
convID, _ := strconv.ParseInt(r.URL.Query().Get("conversation_id"), 10, 64)
h.renderFilesPanel(w, r, filesPanelData{ConversationID: convID})
}
func (h *Handlers) UploadFile(w http.ResponseWriter, r *http.Request) {
convID, _ := strconv.ParseInt(r.PostFormValue("conversation_id"), 10, 64)
data := filesPanelData{ConversationID: convID}
destDir := strings.TrimSpace(r.PostFormValue("dir"))
var headers []*multipart.FileHeader
if r.MultipartForm != nil {
headers = r.MultipartForm.File[uploadFormField]
}
switch {
case len(headers) == 0:
data.UploadError = "Choose at least one file first."
case len(headers) > maxUploadFilesPerRequest:
data.UploadError = fmt.Sprintf("Too many files in one upload (limit %d).", maxUploadFilesPerRequest)
default:
saved, err := h.saveUploadedFiles(r, destDir, headers)
if err != nil {
data.UploadError = err.Error()
} else if len(saved) == 1 {
data.UploadMessage = fmt.Sprintf("Uploaded %s.", saved[0])
} else {
data.UploadMessage = fmt.Sprintf("Uploaded %d files.", len(saved))
}
}
h.renderFilesPanel(w, r, data)
}
func (h *Handlers) saveUploadedFiles(r *http.Request, destDir string, headers []*multipart.FileHeader) ([]string, error) {
saved := make([]string, 0, len(headers))
for _, fh := range headers {
name, err := h.saveUploadedFile(r, destDir, fh)
if err != nil {
return saved, err
}
saved = append(saved, name)
}
return saved, nil
}
func (h *Handlers) saveUploadedFile(r *http.Request, destDir string, fh *multipart.FileHeader) (string, error) {
rel, err := uploadDestPath(destDir, fh.Filename)
if err != nil {
return "", err
}
return h.writeUploadedFile(r, rel, fh)
}
// uploadDestPath replaces the browser-supplied filename with just its
// base name (stripping any directory component, which browsers
// sometimes still send for webkitdirectory-style selections) and
// joins it onto destDir. Split out of saveUploadedFile so
// attachments.go's saveChatAttachment can compute a destination,
// de-duplicate it against what's already in the workspace, and only
// then write — see uniqueWorkspacePath.
func uploadDestPath(destDir, filename string) (string, error) {
base := filepath.Base(filepath.ToSlash(filename))
if base == "" || base == "." || base == "/" {
return "", fmt.Errorf("a file has no usable name")
}
if destDir == "" {
return base, nil
}
return path.Join(destDir, base), nil
}
// writeUploadedFile replaces the second half of the old
// saveUploadedFile: given an already-computed destination path, reads
// and size-checks the multipart part and writes it into the workspace.
func (h *Handlers) writeUploadedFile(r *http.Request, rel string, fh *multipart.FileHeader) (string, error) {
if fh.Size > maxUploadFileBytes {
return "", fmt.Errorf("%q is %s, larger than the %s-per-file limit", fh.Filename, humanBytes(fh.Size), humanBytes(maxUploadFileBytes))
}
src, err := fh.Open()
if err != nil {
return "", fmt.Errorf("%q: %w", fh.Filename, err)
}
defer src.Close()
content, err := io.ReadAll(io.LimitReader(src, maxUploadFileBytes+1))
if err != nil {
return "", fmt.Errorf("%q: read: %w", rel, err)
}
if len(content) > maxUploadFileBytes {
return "", fmt.Errorf("%q exceeds the %s-per-file limit", rel, humanBytes(maxUploadFileBytes))
}
if _, err := h.Workspace.UploadFile(r.Context(), &workspacev1.UploadFileRequest{Path: rel, Content: content}); err != nil {
return "", fmt.Errorf("%q: %w", rel, err)
}
return filepath.ToSlash(rel), nil
}
func (h *Handlers) renderFilesPanel(w http.ResponseWriter, r *http.Request, data filesPanelData) {
files, err := h.listWorkspaceFilesCtx(r)
if err != nil {
h.Logger.Warn("list workspace files", "err", err)
}
data.WorkspaceFiles = files
data.CSRFToken = h.csrfToken(w, r)
h.render(w, http.StatusOK, "workspace_files_panel", data)
}
func (h *Handlers) listWorkspaceFilesCtx(r *http.Request) ([]workspaceFileView, error) {
resp, err := h.Workspace.Tree(r.Context(), &workspacev1.TreeRequest{OneLevel: false})
if err != nil {
return nil, err
}
out := make([]workspaceFileView, 0, len(resp.GetNodes()))
for _, n := range resp.GetNodes() {
out = append(out, workspaceFileView{
Path: n.GetRelPath(), DownloadURL: downloadURLFor(n.GetRelPath()),
Size: n.GetSize(), SizeHuman: humanBytes(n.GetSize()), ModTime: n.GetModifiedAt().AsTime(),
})
}
return out, nil
}
// ---------------------------------------------------------------------
// GET /files/download/{path...}
// ---------------------------------------------------------------------
func (h *Handlers) DownloadFile(w http.ResponseWriter, r *http.Request) {
rel := r.PathValue("path")
if strings.TrimSpace(rel) == "" {
http.NotFound(w, r)
return
}
resp, err := h.Workspace.DownloadFile(r.Context(), &workspacev1.DownloadFileRequest{Path: rel})
if err != nil {
http.NotFound(w, r)
return
}
name := resp.GetFilename()
if name == "" {
name = filepath.Base(rel)
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
w.Header().Set("X-Content-Type-Options", "nosniff")
// Content-Type is set explicitly — left unset, Go's server auto-
// sniffs it from the first 512 bytes the moment we Write() (see
// net/http's chunkWriter.writeHeader), independent of the
// X-Content-Type-Options header above (that only tells the
// browser not to second-guess whatever type the server already
// sent — it doesn't stop the server's own sniffing from running
// in the first place). A .docx/.xlsx/.pptx is, at the byte level,
// a zip archive (PK\x03\x04), so unsniffed it comes back as
// "application/zip" — technically not wrong, but not what Office
// expects either, and actively misleading for anything that
// pays attention to the advertised type rather than just the
// filename extension.
w.Header().Set("Content-Type", downloadContentType(name))
w.Header().Set("Content-Length", strconv.Itoa(len(resp.GetContent())))
w.WriteHeader(http.StatusOK)
_, _ = w.Write(resp.GetContent())
}
// ---------------------------------------------------------------------
// CSRF for multipart uploads — replaces Handlers.RequireCSRFUpload.
// ---------------------------------------------------------------------
func (h *Handlers) RequireCSRFUpload(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadRequestBytes)
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, fmt.Sprintf("upload too large or malformed (limit %s total): %v", humanBytes(maxUploadRequestBytes), err), http.StatusBadRequest)
return
}
cookie, _ := r.Cookie(csrfCookieName)
var cookieVal string
if cookie != nil {
cookieVal = cookie.Value
}
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
if !authsupport.VerifyCSRF(cookieVal, submitted) {
http.Error(w, "invalid or missing CSRF token — reload the page and try again", http.StatusForbidden)
return
}
next(w, r)
}
}