Files
goclaw/internal/http/vault_handler_upload.go
T
viettranx c8ebe9a789 refactor(comments): remove plan/phase refs from agent identity hardening code
Rewrite inline comments added during the agent identity hardening so
they explain the code as it stands today, rather than tying to internal
plan terminology (phase numbers, FR/NFR/H/M/C codes, PR references,
trap zone labels). Commit history already carries the plan archaeology.
Comments now keep the non-obvious invariants (cache boundaries, bypass
gaps, silent-nil traps, dual-tenant semantics) and drop the scaffolding.
Comment-only — no runtime behavior change.
2026-04-11 21:22:23 +07:00

251 lines
7.1 KiB
Go

package http
import (
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/vault"
)
// allowedUploadExts is the whitelist of text-based file extensions accepted by vault upload.
var allowedUploadExts = map[string]bool{
".md": true, ".txt": true, ".json": true, ".yaml": true, ".yml": true,
".csv": true, ".toml": true, ".xml": true, ".html": true, ".htm": true,
".go": true, ".py": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true,
".rs": true, ".java": true, ".rb": true, ".sh": true, ".sql": true,
".swift": true, ".kt": true, ".c": true, ".cpp": true, ".h": true,
}
// handleUpload accepts multipart file uploads and registers them in the vault.
// Files are written to the tenant workspace in the appropriate subfolder based on agent/team selection,
// then UpsertDocument + EventVaultDocUpserted triggers the existing enrichment pipeline.
func (h *VaultHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
tenantID := store.TenantIDFromContext(r.Context())
tenantIDStr := tenantID.String()
// 32 MB in-memory; remainder spills to disk.
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
return
}
defer r.MultipartForm.RemoveAll()
agentIDStr := r.FormValue("agent_id")
teamIDStr := r.FormValue("team_id")
// Boundary UUID validation. validateTeamMembership below short-circuits
// on owner role + lite edition (nil teamAccess), which would leave a
// downstream parseUUIDOrNil(*doc.TeamID) call as a silent-nil trap.
// Validate at the HTTP boundary so bad form input is rejected before any
// store call or event publish.
// See docs/agent-identity-conventions.md.
if agentIDStr != "" {
if _, err := uuid.Parse(agentIDStr); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid agent_id: must be a UUID"})
return
}
}
if teamIDStr != "" {
if _, err := uuid.Parse(teamIDStr); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid team_id: must be a UUID"})
return
}
}
// Validate team membership if provided.
if teamIDStr != "" {
if !h.validateTeamMembership(r.Context(), w, teamIDStr) {
return
}
}
// Resolve agent UUID → agent_key for folder placement.
var agentKey string
if agentIDStr != "" {
if h.agents != nil {
agents, err := h.agents.List(r.Context(), "")
if err == nil {
for _, a := range agents {
if a.ID.String() == agentIDStr {
agentKey = a.AgentKey
break
}
}
}
}
if agentKey == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "agent not found"})
return
}
}
// Determine target subfolder and scope.
var subDir, scope string
switch {
case agentIDStr != "":
subDir = filepath.Join("agents", agentKey)
scope = "personal"
case teamIDStr != "":
subDir = filepath.Join("teams", teamIDStr)
scope = "team"
default:
scope = "shared"
}
wsPath := h.resolveTenantWorkspace(r.Context())
if wsPath == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace not available"})
return
}
targetDir := filepath.Join(wsPath, subDir)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
slog.Warn("vault.upload: mkdir failed", "dir", targetDir, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create target directory"})
return
}
files := r.MultipartForm.File["files"]
if len(files) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no files provided"})
return
}
if len(files) > 50 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "too many files (max 50)"})
return
}
type uploadResult struct {
Doc *store.VaultDocument `json:"document"`
Error string `json:"error,omitempty"`
}
var results []uploadResult
var created int
for _, fh := range files {
// Sanitize filename — basename only, no path traversal.
name := filepath.Base(fh.Filename)
if name == "." || name == ".." || name == "" {
results = append(results, uploadResult{Error: "invalid filename: " + fh.Filename})
continue
}
// Validate extension.
ext := strings.ToLower(filepath.Ext(name))
if !allowedUploadExts[ext] {
results = append(results, uploadResult{Error: "unsupported file type: " + name})
continue
}
// Enforce per-file size limit (50 MB).
if fh.Size > 50<<20 {
results = append(results, uploadResult{Error: "file too large: " + name})
continue
}
// Open uploaded file.
src, err := fh.Open()
if err != nil {
results = append(results, uploadResult{Error: "failed to read: " + name})
continue
}
// Write to workspace.
dstPath := filepath.Join(targetDir, name)
dst, err := os.Create(dstPath)
if err != nil {
src.Close()
results = append(results, uploadResult{Error: "failed to write: " + name})
continue
}
_, copyErr := io.Copy(dst, src)
dst.Close()
src.Close()
if copyErr != nil {
results = append(results, uploadResult{Error: "failed to write: " + name})
continue
}
// Hash the written file.
hash, err := vault.ContentHashFile(dstPath)
if err != nil {
results = append(results, uploadResult{Error: "failed to hash: " + name})
continue
}
// Build workspace-relative path for DB storage.
relPath := name
if subDir != "" {
relPath = subDir + "/" + name
}
doc := &store.VaultDocument{
TenantID: tenantIDStr,
Path: relPath,
Title: vault.InferTitle(relPath),
DocType: vault.InferDocType(relPath),
ContentHash: hash,
Scope: scope,
}
if agentIDStr != "" {
doc.AgentID = &agentIDStr
}
if teamIDStr != "" {
doc.TeamID = &teamIDStr
}
if err := h.store.UpsertDocument(r.Context(), doc); err != nil {
slog.Warn("vault.upload: upsert failed", "path", relPath, "error", err)
results = append(results, uploadResult{Error: "failed to register: " + name})
continue
}
// Publish enrichment event (same pattern as rescan.go).
if h.eventBus != nil {
agentForEvent := ""
if agentIDStr != "" {
agentForEvent = agentIDStr
}
h.eventBus.Publish(eventbus.DomainEvent{
ID: uuid.Must(uuid.NewV7()).String(),
Type: eventbus.EventVaultDocUpserted,
SourceID: doc.ID + ":" + hash,
TenantID: tenantIDStr,
AgentID: agentForEvent,
Timestamp: time.Now(),
Payload: eventbus.VaultDocUpsertedPayload{
DocID: doc.ID,
TenantID: tenantIDStr,
AgentID: agentForEvent,
Path: relPath,
ContentHash: hash,
Workspace: wsPath,
},
})
}
results = append(results, uploadResult{Doc: doc})
created++
}
// Signal enrichment progress for WS subscribers.
if h.enrichProgress != nil && created > 0 {
h.enrichProgress.Start(created, tenantID)
}
slog.Info("vault.upload", "tenant", tenantIDStr, "uploaded", created, "errors", len(files)-created)
writeJSON(w, http.StatusOK, map[string]any{
"documents": results,
"count": created,
})
}