package api import ( "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "strconv" "strings" "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) const maxMaterializedAttachmentRequestBytes int64 = models.MaxMessageAttachmentBytes + 4*1024*1024 type materializedAttachmentResponse struct { Name string `json:"name"` SizeBytes int64 `json:"size_bytes"` } type materializedAttachmentUpload struct { sessionID string attachmentID string name string mimeType string declaredSize int64 fileHeader *multipart.FileHeader } func (s *Server) handleMaterializeAttachment(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMaterializedAttachmentRequestBytes) if err := c.Request.ParseMultipartForm(16 << 20); err != nil { c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "attachment request is too large"}) return } upload, status, message := parseMaterializedAttachmentUpload(c) if status != 0 { c.JSON(status, gin.H{"error": message}) return } response, err := s.persistMaterializedAttachment(upload) if err != nil { if status, message, ok := materializedAttachmentError(err); ok { c.JSON(status, gin.H{"error": message}) return } s.logger.Error("materialize attachment", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "attachment storage unavailable"}) return } c.JSON(http.StatusCreated, response) } func parseMaterializedAttachmentUpload(c *gin.Context) (materializedAttachmentUpload, int, string) { upload := materializedAttachmentUpload{ sessionID: strings.TrimSpace(c.PostForm("session_id")), attachmentID: strings.TrimSpace(c.PostForm("attachment_id")), name: strings.TrimSpace(c.PostForm("name")), mimeType: strings.TrimSpace(c.PostForm("mime_type")), } declaredSize, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("size_bytes")), 10, 64) if upload.sessionID == "" || upload.attachmentID == "" || upload.name == "" || upload.mimeType == "" || err != nil || declaredSize < 0 || declaredSize > models.MaxMessageAttachmentBytes { return upload, http.StatusBadRequest, "attachment metadata is invalid" } upload.declaredSize = declaredSize if !isSafeAttachmentComponent(upload.sessionID) || !isSafeAttachmentComponent(upload.attachmentID) { return upload, http.StatusBadRequest, "attachment identity is invalid" } upload.fileHeader, err = c.FormFile("file") if err != nil { return upload, http.StatusBadRequest, "file is required" } return upload, 0, "" } func (s *Server) persistMaterializedAttachment(upload materializedAttachmentUpload) (materializedAttachmentResponse, error) { file, err := upload.fileHeader.Open() if err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusBadRequest, "file is unreadable") } defer func() { _ = file.Close() }() dir, err := safeAttachmentPath(s.cfg.WorkDir, ".kandev", "attachments", upload.sessionID) if err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusBadRequest, "attachment identity is invalid") } if err := os.MkdirAll(dir, 0o700); err != nil { s.logger.Error("create agent attachment directory", zap.Error(err)) return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusInternalServerError, "attachment storage unavailable") } tmp, err := os.CreateTemp(dir, ".upload-*") if err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusInternalServerError, "attachment storage unavailable") } tmpPath := tmp.Name() cleanup := func() { _ = tmp.Close() _ = os.Remove(tmpPath) } defer cleanup() n, copyErr := io.Copy(tmp, io.LimitReader(file, models.MaxMessageAttachmentBytes+1)) if copyErr != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusBadRequest, "attachment upload failed") } if n != upload.declaredSize || n > models.MaxMessageAttachmentBytes { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusRequestEntityTooLarge, "attachment size does not match its descriptor") } if err := tmp.Close(); err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusInternalServerError, "attachment storage unavailable") } if err := os.Chmod(tmpPath, 0o600); err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusInternalServerError, "attachment storage unavailable") } destinationName := safeAttachmentName(upload.name) if destinationName == "" { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusBadRequest, "attachment name is invalid") } destinationName, err = installMaterializedAttachment(dir, tmpPath, destinationName) if err != nil { return materializedAttachmentResponse{}, newMaterializedAttachmentError(http.StatusInternalServerError, "attachment storage unavailable") } return materializedAttachmentResponse{Name: destinationName, SizeBytes: n}, nil } type materializedAttachmentHTTPError struct { status int message string } func (e *materializedAttachmentHTTPError) Error() string { return e.message } func newMaterializedAttachmentError(status int, message string) error { return &materializedAttachmentHTTPError{status: status, message: message} } func materializedAttachmentError(err error) (int, string, bool) { var httpErr *materializedAttachmentHTTPError if !errors.As(err, &httpErr) { return 0, "", false } return httpErr.status, httpErr.message, true } func safeAttachmentName(name string) string { if !isSafeAttachmentComponent(name) { return "" } return name } func isSafeAttachmentComponent(value string) bool { return value != "" && filepath.IsLocal(value) && filepath.Base(value) == value && !strings.ContainsAny(value, "/\\\x00") } func safeAttachmentPath(root string, components ...string) (string, error) { if root == "" { return "", fmt.Errorf("attachment root is required") } parts := make([]string, 0, len(components)+1) parts = append(parts, root) for _, component := range components { if !isSafeAttachmentComponent(component) { return "", fmt.Errorf("invalid attachment path component") } parts = append(parts, component) } path := filepath.Join(parts...) rel, err := filepath.Rel(root, path) if err != nil || filepath.IsAbs(rel) || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("attachment path escapes root") } return path, nil } func installMaterializedAttachment(dir, tmpPath, name string) (string, error) { ext := filepath.Ext(name) base := strings.TrimSuffix(name, ext) for i := 1; i <= 10000; i++ { candidate := name if i > 1 { candidate = fmt.Sprintf("%s-%d%s", base, i, ext) } candidatePath, err := safeAttachmentPath(dir, candidate) if err != nil { return "", err } if err := os.Link(tmpPath, candidatePath); err != nil { if errors.Is(err, os.ErrExist) { continue } return "", err } if err := os.Remove(tmpPath); err != nil { _ = os.Remove(candidatePath) return "", err } return candidate, nil } return "", fmt.Errorf("no available attachment destination") }