Files
ARTEX/server/chatupload.go
T
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

165 lines
5.6 KiB
Go

package server
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Autumn-27/artex/db"
)
// maxChatUpload caps a single chat-attachment upload request (memory + spill).
const maxChatUpload = 128 << 20 // 128 MiB
// safeChatID guards the {id} path segment against traversal — task ids are numeric,
// session ids are alnum/_/- ; anything with "/" or ".." is rejected.
var safeChatID = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// chatAttachment is one uploaded file as the frontend + agent see it. Path is relative
// to the chat's working dir (e.g. "uploads/report.txt"), which is the agent's CWD, so
// it can Read/Bash the file directly; Name/Size drive the UI card.
type chatAttachment struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
// Abs is the absolute on-disk path (m.dir is already absolute). For pre-task staging
// (scope=staging) the frontend uses it to write the prompt into the description; task/session use composeAgentMessage, which builds the path in the backend.
Abs string `json:"abs,omitempty"`
}
// chatUpload implements method-1 file support: it saves one or more files into a chat's
// working dir under uploads/, so the agent opens them with its existing Read/Bash tools
// and the sent message carries their paths. No LLM-layer change, no multimodal.
//
// POST /api/chat/upload?scope=task|session|staging&id=<id>, multipart field "file"
// (repeatable). Returns {attachments:[{name,path,size,abs}]}. Target dir mirrors the
// agent CWD layout:
//
// scope=task → <workDir>/tasks/<id>/uploads/
// scope=session → <workDir>/sessions/<id>/uploads/
// scope=staging → <workDir>/drafts/<id>/uploads/ (pre-task staging: the task has no ID yet;
// files land here first and the frontend writes their abs paths into the task description)
func (s *Server) chatUpload(w http.ResponseWriter, r *http.Request) {
var sub string
taskScoped := false
switch r.URL.Query().Get("scope") {
case "task":
sub = "tasks"
taskScoped = true
case "session":
sub = "sessions"
case "staging":
sub = "drafts"
default:
writeErr(w, 400, "scope must be task / session / staging")
return
}
id := r.URL.Query().Get("id")
if !safeChatID.MatchString(id) {
writeErr(w, 400, "invalid id")
return
}
if taskScoped {
if s.m.ResolveTask(id) == nil {
writeErr(w, 404, "task not found")
return
}
if !s.engine.beginTaskOperation(id) {
writeErr(w, http.StatusConflict, "Task is being deleted; cannot upload attachments")
return
}
defer s.engine.decInflight(id)
}
dir := filepath.Join(s.m.dir, sub, id, "uploads")
if err := os.MkdirAll(dir, 0o755); err != nil {
writeErr(w, 500, "failed to create directory: "+err.Error())
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxChatUpload)
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeErr(w, 400, "failed to parse the 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
}
out := make([]chatAttachment, 0, len(files))
for _, hdr := range files {
name := filepath.Base(hdr.Filename) // strip any path component
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
continue
}
dest := uniqueUploadPath(dir, name)
if err := saveUpload(hdr, dest); err != nil {
writeErr(w, 500, "Save failed: "+err.Error())
return
}
base := filepath.Base(dest)
out = append(out, chatAttachment{Name: base, Path: "uploads/" + base, Size: hdr.Size, Abs: dest})
}
writeJSON(w, 200, map[string]any{"attachments": out})
}
// uniqueUploadPath returns dir/name, or dir/name-1, dir/name-2… when it already exists,
// so re-uploading the same filename never clobbers a prior attachment.
func uniqueUploadPath(dir, name string) string {
dest := filepath.Join(dir, name)
if _, err := os.Stat(dest); os.IsNotExist(err) {
return dest
}
ext := filepath.Ext(name)
stem := strings.TrimSuffix(name, ext)
for i := 1; ; i++ {
cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext))
if _, err := os.Stat(cand); os.IsNotExist(err) {
return cand
}
}
}
// composeAgentMessage appends an attachment manifest to the user's message so the agent
// knows which files were uploaded and where to Read them. baseDir is the agent's working
// dir (its CWD); we emit ABSOLUTE paths (baseDir + relative) so the agent can Read/Bash
// them unambiguously regardless of how it interprets relative paths.
func composeAgentMessage(msg string, atts []chatAttachment, baseDir string) string {
if len(atts) == 0 {
return msg
}
var b strings.Builder
b.WriteString(msg)
b.WriteString("\n\n[User-uploaded attachments] (absolute paths; use Read/Bash to view them):")
for _, a := range atts {
fmt.Fprintf(&b, "\n- %s (%s)", filepath.Join(baseDir, a.Path), humanBytes(a.Size))
}
return b.String()
}
// userActivityWithAttachments builds the persisted 'user' activity. With attachments,
// Detail holds JSON {text, attachments} so the transcript renders text + attachment
// cards; Summary stays the plain text (the list payload omits Detail, lazy-loaded).
func userActivityWithAttachments(worker, text string, atts []chatAttachment) db.Activity {
a := db.Activity{Worker: worker, Kind: "user", Summary: text}
if len(atts) > 0 {
blob, _ := json.Marshal(map[string]any{"text": text, "attachments": atts})
a.Detail = string(blob)
}
return a
}
func humanBytes(n int64) string {
switch {
case n >= 1<<20:
return fmt.Sprintf("%.1f MB", float64(n)/(1<<20))
case n >= 1<<10:
return fmt.Sprintf("%.1f KB", float64(n)/(1<<10))
default:
return fmt.Sprintf("%d B", n)
}
}