273 lines
9.6 KiB
Go
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)
|
|
}
|
|
}
|