From 8b0a72c81f9f71766b91ac1a3db8d8698bc6ff44 Mon Sep 17 00:00:00 2001 From: Aaron Guise Date: Tue, 1 Jul 2025 11:04:32 +1200 Subject: [PATCH] feat: implement attachment REST API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /attachments for secure multi-file upload with validation - Add GET /attachments for listing transaction attachments - Add GET /attachments/:id for attachment metadata retrieval - Add GET /attachments/:id/download for secure file download - Add DELETE /attachments/:id for soft deletion - Include comprehensive security validation: file type, size, content detection - Implement proper error handling and cleanup on failures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- core/api/attachment.go | 313 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 core/api/attachment.go diff --git a/core/api/attachment.go b/core/api/attachment.go new file mode 100644 index 0000000..60ecb78 --- /dev/null +++ b/core/api/attachment.go @@ -0,0 +1,313 @@ +package api + +import ( + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/ant0ine/go-json-rest/rest" + "github.com/openaccounting/oa-server/core/model" + "github.com/openaccounting/oa-server/core/model/types" + "github.com/openaccounting/oa-server/core/util" +) + +const ( + MaxFileSize = 10 * 1024 * 1024 // 10MB + MaxFilesPerTx = 10 + AttachmentDir = "attachments" +) + +var AllowedMimeTypes = map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "application/pdf": true, + "text/plain": true, + "text/csv": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, // .xlsx + "application/vnd.ms-excel": true, // .xls +} + +func PostAttachment(w rest.ResponseWriter, r *rest.Request) { + orgId := r.PathParam("orgId") + transactionId := r.PathParam("transactionId") + + if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) { + rest.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + user := r.Env["USER"].(*types.User) + + // Parse multipart form + err := r.ParseMultipartForm(MaxFileSize) + if err != nil { + rest.Error(w, "Failed to parse multipart form", http.StatusBadRequest) + return + } + + files := r.MultipartForm.File["files"] + if len(files) == 0 { + rest.Error(w, "No files provided", http.StatusBadRequest) + return + } + + if len(files) > MaxFilesPerTx { + rest.Error(w, fmt.Sprintf("Too many files. Maximum %d files allowed", MaxFilesPerTx), http.StatusBadRequest) + return + } + + // Verify transaction exists and user has permission + tx, err := model.Instance.GetTransaction(transactionId, orgId, user.Id) + if err != nil { + rest.Error(w, "Transaction not found or access denied", http.StatusNotFound) + return + } + if tx == nil { + rest.Error(w, "Transaction not found", http.StatusNotFound) + return + } + + var attachments []*types.Attachment + var description string + if desc := r.FormValue("description"); desc != "" { + description = desc + } + + for _, fileHeader := range files { + attachment, err := processFileUpload(fileHeader, transactionId, orgId, user.Id, description) + if err != nil { + // Clean up any successfully uploaded files + for _, att := range attachments { + os.Remove(att.FilePath) + } + rest.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Save attachment to database + createdAttachment, err := model.Instance.CreateAttachment(attachment) + if err != nil { + // Clean up file and any previously uploaded files + os.Remove(attachment.FilePath) + for _, att := range attachments { + os.Remove(att.FilePath) + } + rest.Error(w, "Failed to save attachment", http.StatusInternalServerError) + return + } + + attachments = append(attachments, createdAttachment) + } + + w.WriteJson(map[string]interface{}{ + "attachments": attachments, + "count": len(attachments), + }) +} + +func GetAttachments(w rest.ResponseWriter, r *rest.Request) { + orgId := r.PathParam("orgId") + transactionId := r.PathParam("transactionId") + + if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) { + rest.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + user := r.Env["USER"].(*types.User) + + attachments, err := model.Instance.GetAttachmentsByTransaction(transactionId, orgId, user.Id) + if err != nil { + rest.Error(w, "Failed to retrieve attachments", http.StatusInternalServerError) + return + } + + w.WriteJson(attachments) +} + +func GetAttachment(w rest.ResponseWriter, r *rest.Request) { + orgId := r.PathParam("orgId") + transactionId := r.PathParam("transactionId") + attachmentId := r.PathParam("attachmentId") + + if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) { + rest.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + user := r.Env["USER"].(*types.User) + + attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id) + if err != nil { + rest.Error(w, "Attachment not found or access denied", http.StatusNotFound) + return + } + + w.WriteJson(attachment) +} + +func DownloadAttachment(w rest.ResponseWriter, r *rest.Request) { + orgId := r.PathParam("orgId") + transactionId := r.PathParam("transactionId") + attachmentId := r.PathParam("attachmentId") + + if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) { + rest.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + user := r.Env["USER"].(*types.User) + + attachment, err := model.Instance.GetAttachment(attachmentId, transactionId, orgId, user.Id) + if err != nil { + rest.Error(w, "Attachment not found or access denied", http.StatusNotFound) + return + } + + // Check if file exists + if _, err := os.Stat(attachment.FilePath); os.IsNotExist(err) { + rest.Error(w, "File not found on disk", http.StatusNotFound) + return + } + + // Set headers for file download + w.Header().Set("Content-Type", attachment.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", attachment.OriginalName)) + + // Open and serve file + file, err := os.Open(attachment.FilePath) + if err != nil { + rest.Error(w, "Failed to open file", http.StatusInternalServerError) + return + } + defer file.Close() + + io.Copy(w.(http.ResponseWriter), file) +} + +func DeleteAttachment(w rest.ResponseWriter, r *rest.Request) { + orgId := r.PathParam("orgId") + transactionId := r.PathParam("transactionId") + attachmentId := r.PathParam("attachmentId") + + if !util.IsValidUUID(orgId) || !util.IsValidUUID(transactionId) || !util.IsValidUUID(attachmentId) { + rest.Error(w, "Invalid UUID format", http.StatusBadRequest) + return + } + + user := r.Env["USER"].(*types.User) + + err := model.Instance.DeleteAttachment(attachmentId, transactionId, orgId, user.Id) + if err != nil { + rest.Error(w, "Failed to delete attachment or access denied", http.StatusInternalServerError) + return + } + + w.WriteJson(map[string]string{"status": "deleted"}) +} + +func processFileUpload(fileHeader *multipart.FileHeader, transactionId, orgId, userId, description string) (*types.Attachment, error) { + // Validate file size + if fileHeader.Size > MaxFileSize { + return nil, fmt.Errorf("file too large. Maximum size is %d bytes", MaxFileSize) + } + + // Open uploaded file + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open uploaded file: %v", err) + } + defer file.Close() + + // Validate file type from header + contentType := fileHeader.Header.Get("Content-Type") + if !AllowedMimeTypes[contentType] { + return nil, fmt.Errorf("file type %s not allowed", contentType) + } + + // Validate file type by detecting content (more secure) + buffer := make([]byte, 512) + n, err := file.Read(buffer) + if err != nil { + return nil, fmt.Errorf("failed to read file for content detection: %v", err) + } + + // Reset file pointer to beginning + if _, err := file.Seek(0, 0); err != nil { + return nil, fmt.Errorf("failed to reset file pointer: %v", err) + } + + detectedType := http.DetectContentType(buffer[:n]) + if !AllowedMimeTypes[detectedType] { + return nil, fmt.Errorf("detected file type %s not allowed (header claimed %s)", detectedType, contentType) + } + + // Generate unique filename + attachmentId := util.NewUUID() + ext := filepath.Ext(fileHeader.Filename) + fileName := attachmentId + ext + + // Create attachments directory if it doesn't exist + uploadDir := filepath.Join(AttachmentDir, orgId, transactionId) + if err := os.MkdirAll(uploadDir, 0755); err != nil { + return nil, fmt.Errorf("failed to create upload directory: %v", err) + } + + // Create file path + filePath := filepath.Join(uploadDir, fileName) + + // Create destination file + dst, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("failed to create destination file: %v", err) + } + defer dst.Close() + + // Copy file contents + if _, err := io.Copy(dst, file); err != nil { + return nil, fmt.Errorf("failed to save file: %v", err) + } + + // Create attachment object + attachment := &types.Attachment{ + Id: attachmentId, + TransactionId: transactionId, + OrgId: orgId, + UserId: userId, + FileName: fileName, + OriginalName: fileHeader.Filename, + ContentType: contentType, + FileSize: fileHeader.Size, + FilePath: filePath, + Description: description, + Uploaded: time.Now(), + Deleted: false, + } + + return attachment, nil +} + +func sanitizeFilename(filename string) string { + // Remove potentially dangerous characters + filename = strings.ReplaceAll(filename, "..", "") + filename = strings.ReplaceAll(filename, "/", "") + filename = strings.ReplaceAll(filename, "\\", "") + filename = strings.ReplaceAll(filename, "\x00", "") // null bytes + filename = strings.ReplaceAll(filename, "\r", "") // carriage return + filename = strings.ReplaceAll(filename, "\n", "") // newline + + // Limit filename length + if len(filename) > 255 { + ext := filepath.Ext(filename) + base := filename[:255-len(ext)] + filename = base + ext + } + + return filename +} \ No newline at end of file