mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-01 10:43:49 +08:00
375 lines
11 KiB
Go
375 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"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=<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)
|
|
}
|
|
|
|
// GET /api/workspace/preview?path=<rel> — 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-<node>-step-<n>.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 != ""
|
|
}
|
|
|
|
// 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
|
|
}
|