Files
ARTEX/server/workspace.go
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02: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, "invalid path")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "path does not exist")
return
}
if !fi.IsDir() {
writeErr(w, 400, "not a directory")
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(),
})
}
// Directories first, each sorted by name.
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, "invalid path")
return
}
fi, err := os.Stat(abs)
if err != nil {
writeErr(w, 404, "file does not exist")
return
}
if fi.IsDir() {
writeErr(w, 400, "is a directory, cannot be read as a file")
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, "invalid path")
return
}
if fi, err := os.Stat(abs); err == nil && fi.IsDir() {
writeErr(w, 400, "target is a directory")
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, "invalid path")
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, "invalid path")
return
}
if abs == filepath.Clean(s.m.dir) {
writeErr(w, 400, "cannot delete the workspace root directory")
return
}
if _, err := os.Stat(abs); err != nil {
writeErr(w, 404, "path does not exist")
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, "invalid path")
return
}
fi, err := os.Stat(abs)
if err != nil || fi.IsDir() {
writeErr(w, 404, "file does not exist")
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, "invalid path")
return
}
if fi, err := os.Stat(dirAbs); err != nil || !fi.IsDir() {
writeErr(w, 400, "target directory does not exist")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxWorkspaceUpload)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErr(w, 400, "failed to parse upload or size limit exceeded: "+err.Error())
return
}
files := r.MultipartForm.File["file"]
if len(files) == 0 {
writeErr(w, 400, "missing upload file (form field 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
}