Files
Agent-System/internal/server/asset_handlers.go
T
weichenandCursor 5856585d1d feat(ops): compose runner auth, upload MIME validation, and test soak
Wire RUNNER_AUTH_TOKEN through dev/test/main Compose, validate uploads
with extension whitelist and content sniffing, add readiness soak script
and short PG loop test, and require runner token in prod when URL is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:23 +00:00

145 lines
3.7 KiB
Go

package server
import (
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"chat-agent-system/internal/upload"
)
type projectAssetResponse struct {
AssetID int64 `json:"asset_id"`
Filename string `json:"filename"`
SHA256 string `json:"sha256"`
MimeType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
ProjectID int64 `json:"project_id"`
}
func (s *Server) handleUploadProjectAsset(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
return
}
user, ok := s.requireProjectAccess(w, r, projectID)
if !ok {
return
}
userID := user.ID
if err := r.ParseMultipartForm(defaultUploadBodyLimit); err != nil {
if isRequestBodyTooLarge(err) {
http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "invalid multipart form: "+err.Error(), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
http.Error(w, "read file failed", http.StatusBadRequest)
return
}
if len(data) == 0 {
http.Error(w, "cannot store empty file", http.StatusBadRequest)
return
}
filename := header.Filename
if filename == "" {
filename = "upload.bin"
}
declaredMIME := header.Header.Get("Content-Type")
mimeType, verr := upload.ValidateProjectMaterial(filename, declaredMIME, data)
if verr != nil {
http.Error(w, "upload rejected: "+verr.Error(), http.StatusUnsupportedMediaType)
return
}
cas := s.svc.CAS()
if cas == nil {
http.Error(w, "storage unavailable", http.StatusServiceUnavailable)
return
}
ref, err := cas.StoreFile(r.Context(), projectID, userID, filepath.Base(filename), "material", data, mimeType)
if err != nil {
http.Error(w, "store asset failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSONResponse(w, http.StatusOK, projectAssetResponse{
AssetID: ref.ID,
Filename: ref.FileName,
SHA256: ref.BlobSHA256,
MimeType: mimeType,
SizeBytes: int64(len(data)),
ProjectID: projectID,
})
}
func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) {
projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || projectID <= 0 {
http.Error(w, "invalid project id", http.StatusBadRequest)
return
}
if _, ok := s.requireProjectAccess(w, r, projectID); !ok {
return
}
cas := s.svc.CAS()
if cas == nil {
writeJSONResponse(w, http.StatusOK, []projectAssetResponse{})
return
}
refs, err := cas.ListProjectRefs(r.Context(), projectID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out := make([]projectAssetResponse, 0, len(refs))
for _, ref := range refs {
out = append(out, projectAssetResponse{
AssetID: ref.ID,
Filename: ref.FileName,
SHA256: ref.BlobSHA256,
ProjectID: ref.ProjectID,
})
}
writeJSONResponse(w, http.StatusOK, out)
}
func parseUserIDHeader(r *http.Request) int64 {
raw := strings.TrimSpace(r.Header.Get("X-User-ID"))
if raw == "" {
return 0
}
id, _ := strconv.ParseInt(raw, 10, 64)
return id
}
func mimeFromExt(name string) string {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".log":
return "text/plain"
case ".pdf":
return "application/pdf"
case ".docx":
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
default:
return "application/octet-stream"
}
}