package server import ( "bytes" "fmt" "io" "mime/multipart" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "time" "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= 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= — 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= — 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= — 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) } // GET /api/workspace/preview?path= — serve an image inline for report // Markdown. Unlike download, this uses an inline content disposition so a // report can render a saved browser screenshot with a normal Markdown image URL. func (s *Server) wsPreview(w http.ResponseWriter, r *http.Request) { rawPath := strings.TrimSpace(r.URL.Query().Get("path")) abs, ok := s.wsResolve(rawPath) if filepath.IsAbs(rawPath) { base := filepath.Clean(s.m.dir) candidate := filepath.Clean(rawPath) ok = candidate == base || strings.HasPrefix(candidate, base+string(os.PathSeparator)) if ok { abs = candidate } } if !ok { writeErr(w, 400, "非法路径") return } fi, err := os.Stat(abs) // Reports use the workspace-relative canonical path // findings/screenshots/finding--step-.png, while the files live // under the per-conversation session directory. Resolve that exact suffix // in the newest matching session so the links stay portable across reports. if (err != nil || fi.IsDir()) && !filepath.IsAbs(rawPath) { if fallback, found := s.findSessionPreview(rawPath); found { abs = fallback fi, err = os.Stat(abs) } } if err != nil || fi.IsDir() { writeErr(w, 404, "文件不存在") return } ext := strings.ToLower(filepath.Ext(abs)) if ext != ".png" && ext != ".jpg" && ext != ".jpeg" && ext != ".webp" && ext != ".gif" { writeErr(w, 415, "仅支持图片预览") return } if s.m.pg.GetBool(settingScreenshotTimestamp, false) { rawRel := filepath.ToSlash(strings.TrimPrefix(strings.TrimSpace(rawPath), "/")) rel := filepath.ToSlash(s.wsRel(abs)) if strings.HasPrefix(rawRel, "findings/screenshots/") || strings.HasPrefix(rel, "findings/screenshots/") || strings.Contains(rel, "/findings/screenshots/") { // The frontend renders this as a small top-right overlay. Use the // file mtime so the label reflects capture/save time, not view time. // Containers may run with UTC even when the host is configured for // China Standard Time, so make the report display zone explicit. w.Header().Set("X-Report-Screenshot-Timestamp", reportScreenshotTimestamp(fi.ModTime())) } } w.Header().Set("Content-Disposition", "inline; filename=\""+sanitizeFilename(filepath.Base(abs))+"\"") http.ServeFile(w, r, abs) } func reportScreenshotTimestamp(t time.Time) string { return t.In(time.FixedZone("CST", 8*60*60)).Format("2006-01-02 15:04:05") } func (s *Server) findSessionPreview(rawPath string) (string, bool) { rel := filepath.Clean(strings.TrimPrefix(strings.TrimSpace(rawPath), "/")) const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator) if rel == "." || !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) { return "", false } base := filepath.Join(filepath.Clean(s.m.dir), "sessions") matches, err := filepath.Glob(filepath.Join(base, "*", filepath.FromSlash(rel))) if err != nil || len(matches) == 0 { return "", false } var newest string var newestTime int64 for _, candidate := range matches { info, statErr := os.Stat(candidate) if statErr != nil || info.IsDir() { continue } mtime := info.ModTime().UnixNano() if newest == "" || mtime > newestTime { newest, newestTime = candidate, mtime } } return newest, newest != "" } // archiveFindingScreenshots materializes report-referenced screenshots in the // task workspace. Browser runs use per-session working directories, while // reports intentionally use the portable findings/screenshots/ path. func (s *Server) archiveFindingScreenshots(taskID int64, markdown string) error { if taskID <= 0 { return nil } for _, match := range screenshotMarkdownLink.FindAllStringSubmatch(markdown, -1) { raw := strings.TrimSpace(match[2]) rel := filepath.Clean(strings.TrimPrefix(raw, "/")) const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator) if !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) { continue } target := filepath.Join(s.m.dir, "tasks", fmt.Sprint(taskID), rel) if info, err := os.Stat(target); err == nil && !info.IsDir() { continue } source, ok := s.resolveSessionScreenshot(rel) if !ok { continue } if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { return err } in, err := os.Open(source) if err != nil { return err } out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err == nil { _, err = io.Copy(out, in) } _ = in.Close() _ = out.Close() if err != nil { return err } } return nil } func (s *Server) resolveSessionScreenshot(rel string) (string, bool) { matches, err := filepath.Glob(filepath.Join(filepath.Clean(s.m.dir), "sessions", "*", rel)) if err != nil || len(matches) == 0 { return "", false } var newest string var newestTime int64 for _, candidate := range matches { info, statErr := os.Stat(candidate) if statErr != nil || info.IsDir() { continue } if newest == "" || info.ModTime().UnixNano() > newestTime { newest, newestTime = candidate, info.ModTime().UnixNano() } } return newest, newest != "" } // POST /api/workspace/upload?path= — 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 }