Files
Autumn-27andClaude Opus 4.8 6bffd491ee feat(workspace): 新增工作空间文件管理器(针对 workDir)
后端 server/workspace.go(全部在 requireAuth 之后,路径限定在 workDir):
- list/read/write/mkdir/delete/download/upload 七个端点
- wsResolve 用 Clean("/"+rel)+前缀校验防 .. 遍历;上传经 filepath.Base
  再二次校验;读文本 2MB 上限(超限/二进制仅下载);上传 512MB 上限;
  下载文件名过滤 + filename* UTF-8 编码

前端:
- 侧边栏「工作空间」入口(/function/workspace)
- api/types:workspaceList/read/write/mkdir/delete + upload(FormData)
  + download(blob 带 token)
- page:面包屑导航 + 目录表格 + 上传/新建文件夹/刷新 + 每行下载/删除
  + 右侧 Sheet 文本编辑器(二进制/超大仅下载)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-09 01:01:19 -04:00

291 lines
8.1 KiB
Go

package server
import (
"bytes"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"unicode/utf8"
)
// Workspace file manager — browse / view / edit / download / upload / delete the
// shared work dir (s.m.dir), where all agents write their artifacts. Every path is
// confined to the work dir root (traversal via ".." is neutralised). All routes sit
// behind requireAuth (see Handler()).
const (
maxWorkspaceRead = 2 << 20 // 2 MiB: files bigger than this aren't inlined for view/edit (download instead)
maxWorkspaceUpload = 512 << 20 // 512 MiB per upload request
)
// wsResolve maps a user-supplied relative path to an absolute path INSIDE the work
// dir. It returns ok=false if the path would escape the root. filepath.Clean on a
// rooted copy collapses any ".." so nothing can climb above the root.
func (s *Server) wsResolve(rel string) (string, bool) {
base := filepath.Clean(s.m.dir)
rel = strings.TrimPrefix(strings.TrimSpace(rel), "/")
clean := filepath.Clean("/" + rel) // e.g. "/a/../../etc" → "/etc" (still rooted at "/")
abs := filepath.Clean(filepath.Join(base, clean))
if abs != base && !strings.HasPrefix(abs, base+string(os.PathSeparator)) {
return "", false
}
return abs, true
}
// wsRel renders an absolute path back as a workspace-relative path (forward slashes).
func (s *Server) wsRel(abs string) string {
base := filepath.Clean(s.m.dir)
rel, err := filepath.Rel(base, abs)
if err != nil || rel == "." {
return ""
}
return filepath.ToSlash(rel)
}
type wsEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Dir bool `json:"dir"`
Size int64 `json:"size"`
MTime int64 `json:"mtime"` // unix millis
}
// GET /api/workspace/list?path=<rel>
func (s *Server) wsList(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "路径不存在")
return
}
if !fi.IsDir() {
writeErr(w, 400, "不是目录")
return
}
ents, err := os.ReadDir(abs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
out := make([]wsEntry, 0, len(ents))
for _, e := range ents {
info, err := e.Info()
if err != nil {
continue
}
out = append(out, wsEntry{
Name: e.Name(),
Path: s.wsRel(filepath.Join(abs, e.Name())),
Dir: e.IsDir(),
Size: info.Size(),
MTime: info.ModTime().UnixMilli(),
})
}
// 目录在前,各自按名称排序。
sort.Slice(out, func(i, j int) bool {
if out[i].Dir != out[j].Dir {
return out[i].Dir
}
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "entries": out})
}
// GET /api/workspace/read?path=<rel> — inline text for view/edit. Binary or oversize
// files return {binary:true}/{too_large:true} with no content (use download instead).
func (s *Server) wsRead(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "文件不存在")
return
}
if fi.IsDir() {
writeErr(w, 400, "是目录,不能作为文件读取")
return
}
if fi.Size() > maxWorkspaceRead {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "too_large": true, "binary": true})
return
}
data, err := os.ReadFile(abs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": true})
return
}
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": false, "content": string(data)})
}
// POST /api/workspace/write {path, content} — create/overwrite a text file.
func (s *Server) wsWrite(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
abs, ok := s.wsResolve(req.Path)
if !ok || abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "非法路径")
return
}
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
writeErr(w, 400, "目标是目录")
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
if err := os.WriteFile(abs, []byte(req.Content), 0o644); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
}
// POST /api/workspace/mkdir {path}
func (s *Server) wsMkdir(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
}
if err := decode(r, &req); err != nil {
writeErr(w, 400, err.Error())
return
}
abs, ok := s.wsResolve(req.Path)
if !ok || abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "非法路径")
return
}
if err := os.MkdirAll(abs, 0o755); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true, "path": s.wsRel(abs)})
}
// DELETE /api/workspace/delete?path=<rel> — removes a file or a directory tree
// (confined to the work dir; the root itself can't be deleted).
func (s *Server) wsDelete(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
if abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "不能删除工作区根目录")
return
}
if _, err := os.Stat(abs); err != nil {
writeErr(w, 404, "路径不存在")
return
}
if err := os.RemoveAll(abs); err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"ok": true})
}
// GET /api/workspace/download?path=<rel> — stream a file as an attachment.
func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
abs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
fi, err := os.Stat(abs)
if err != nil || fi.IsDir() {
writeErr(w, 404, "文件不存在")
return
}
name := filepath.Base(abs)
// RFC 5987 filename* keeps non-ASCII names intact; plain filename is the fallback.
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name))
http.ServeFile(w, r, abs)
}
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
if !ok {
writeErr(w, 400, "非法路径")
return
}
if fi, err := os.Stat(dirAbs); err != nil || !fi.IsDir() {
writeErr(w, 400, "目标目录不存在")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceUpload)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErr(w, 400, "解析上传失败或超出大小限制:"+err.Error())
return
}
files := r.MultipartForm.File["file"]
if len(files) == 0 {
writeErr(w, 400, "缺少上传文件(表单字段 file)")
return
}
saved := 0
for _, hdr := range files {
name := filepath.Base(hdr.Filename) // strip any path component
if name == "" || name == "." || name == ".." {
continue
}
destAbs, okd := s.wsResolve(filepath.Join(s.wsRel(dirAbs), name))
if !okd {
continue
}
if err := saveUpload(hdr, destAbs); err != nil {
writeErr(w, 500, err.Error())
return
}
saved++
}
writeJSON(w, 200, map[string]any{"uploaded": saved})
}
func saveUpload(hdr *multipart.FileHeader, dest string) error {
src, err := hdr.Open()
if err != nil {
return err
}
defer src.Close()
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, src)
return err
}
// sanitizeFilename strips characters unsafe for a Content-Disposition filename token.
func sanitizeFilename(name string) string {
name = strings.ReplaceAll(name, "\"", "")
name = strings.ReplaceAll(name, "\\", "")
name = strings.ReplaceAll(name, "\n", "")
name = strings.ReplaceAll(name, "\r", "")
return name
}