package api import ( "fmt" "io" "mime/multipart" "net/http" "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/storage" "github.com/openaccounting/oa-server/core/util" "github.com/openaccounting/oa-server/core/util/id" ) // AttachmentHandler handles attachment operations with configurable storage type AttachmentHandler struct { storage storage.Storage } // Global attachment handler instance (will be initialized during server startup) var attachmentHandler *AttachmentHandler // InitializeAttachmentHandler initializes the global attachment handler with storage backend func InitializeAttachmentHandler(storageConfig storage.Config) error { storageBackend, err := storage.NewStorage(storageConfig) if err != nil { return fmt.Errorf("failed to initialize storage backend: %w", err) } attachmentHandler = &AttachmentHandler{ storage: storageBackend, } return nil } // PostAttachmentWithStorage handles file upload using the configured storage backend func PostAttachmentWithStorage(w rest.ResponseWriter, r *rest.Request) { if attachmentHandler == nil { rest.Error(w, "Storage backend not initialized", http.StatusInternalServerError) return } transactionId := r.FormValue("transactionId") if transactionId == "" { rest.Error(w, "Transaction ID is required", http.StatusBadRequest) return } if !util.IsValidUUID(transactionId) { rest.Error(w, "Invalid transaction ID 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["file"] if len(files) == 0 { rest.Error(w, "No file provided", http.StatusBadRequest) return } fileHeader := files[0] // Take the first file // Verify transaction exists and user has permission tx, err := model.Instance.GetTransaction(transactionId, "", user.Id) if err != nil { rest.Error(w, "Transaction not found", http.StatusNotFound) return } if tx == nil { rest.Error(w, "Transaction not found", http.StatusNotFound) return } // Process the file upload attachment, err := attachmentHandler.processFileUploadWithStorage(fileHeader, transactionId, tx.OrgId, user.Id, r.FormValue("description")) if err != nil { rest.Error(w, err.Error(), http.StatusBadRequest) return } // Save attachment to database createdAttachment, err := model.Instance.CreateAttachment(attachment) if err != nil { // Clean up the stored file on database error attachmentHandler.storage.Delete(attachment.FilePath) rest.Error(w, "Failed to save attachment", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) w.WriteJson(createdAttachment) } // GetAttachmentWithStorage retrieves an attachment using the configured storage backend func GetAttachmentWithStorage(w rest.ResponseWriter, r *rest.Request) { if attachmentHandler == nil { rest.Error(w, "Storage backend not initialized", http.StatusInternalServerError) return } attachmentId := r.PathParam("id") if !util.IsValidUUID(attachmentId) { rest.Error(w, "Invalid attachment ID format", http.StatusBadRequest) return } user := r.Env["USER"].(*types.User) // Get attachment from database attachment, err := model.Instance.GetAttachment(attachmentId, "", "", user.Id) if err != nil { rest.Error(w, "Attachment not found", http.StatusNotFound) return } // Check if this is a download request if r.URL.Query().Get("download") == "true" { // Stream the file directly to the client err := attachmentHandler.streamFile(w, attachment) if err != nil { rest.Error(w, "Failed to retrieve file", http.StatusInternalServerError) return } return } // Return attachment metadata w.WriteJson(attachment) } // GetAttachmentDownloadURL returns a download URL for an attachment func GetAttachmentDownloadURL(w rest.ResponseWriter, r *rest.Request) { if attachmentHandler == nil { rest.Error(w, "Storage backend not initialized", http.StatusInternalServerError) return } attachmentId := r.PathParam("id") if !util.IsValidUUID(attachmentId) { rest.Error(w, "Invalid attachment ID format", http.StatusBadRequest) return } user := r.Env["USER"].(*types.User) // Get attachment from database attachment, err := model.Instance.GetAttachment(attachmentId, "", "", user.Id) if err != nil { rest.Error(w, "Attachment not found", http.StatusNotFound) return } // Generate download URL (valid for 1 hour) url, err := attachmentHandler.storage.GetURL(attachment.FilePath, time.Hour) if err != nil { rest.Error(w, "Failed to generate download URL", http.StatusInternalServerError) return } response := map[string]string{ "url": url, "expiresIn": "3600", // 1 hour in seconds } w.WriteJson(response) } // DeleteAttachmentWithStorage deletes an attachment using the configured storage backend func DeleteAttachmentWithStorage(w rest.ResponseWriter, r *rest.Request) { if attachmentHandler == nil { rest.Error(w, "Storage backend not initialized", http.StatusInternalServerError) return } attachmentId := r.PathParam("id") if !util.IsValidUUID(attachmentId) { rest.Error(w, "Invalid attachment ID format", http.StatusBadRequest) return } user := r.Env["USER"].(*types.User) // Get attachment from database first attachment, err := model.Instance.GetAttachment(attachmentId, "", "", user.Id) if err != nil { rest.Error(w, "Attachment not found", http.StatusNotFound) return } // Delete from database (soft delete) err = model.Instance.DeleteAttachment(attachmentId, attachment.TransactionId, attachment.OrgId, user.Id) if err != nil { rest.Error(w, "Failed to delete attachment", http.StatusInternalServerError) return } // Delete from storage backend // Note: For production, you might want to delay physical deletion // and run a cleanup job later to handle any issues err = attachmentHandler.storage.Delete(attachment.FilePath) if err != nil { // Log the error but don't fail the request since database deletion succeeded // The file can be cleaned up later by a maintenance job fmt.Printf("Warning: Failed to delete file from storage: %v\n", err) } w.WriteHeader(http.StatusOK) w.WriteJson(map[string]string{"status": "deleted"}) } // processFileUploadWithStorage processes a file upload using the storage backend func (h *AttachmentHandler) processFileUploadWithStorage(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) } // Validate content type contentType := fileHeader.Header.Get("Content-Type") if !AllowedMimeTypes[contentType] { return nil, fmt.Errorf("unsupported file type: %s", contentType) } // Open the file file, err := fileHeader.Open() if err != nil { return nil, fmt.Errorf("failed to open uploaded file: %w", err) } defer file.Close() // Store the file using the storage backend storagePath, err := h.storage.Store(fileHeader.Filename, file, contentType) if err != nil { return nil, fmt.Errorf("failed to store file: %w", err) } // Create attachment record attachment := &types.Attachment{ Id: id.String(id.New()), TransactionId: transactionId, OrgId: orgId, UserId: userId, FileName: storagePath, // Store the storage path/key OriginalName: fileHeader.Filename, ContentType: contentType, FileSize: fileHeader.Size, FilePath: storagePath, // For backward compatibility Description: description, Uploaded: time.Now(), Deleted: false, } return attachment, nil } // streamFile streams a file from storage to the HTTP response func (h *AttachmentHandler) streamFile(w rest.ResponseWriter, attachment *types.Attachment) error { // Get file from storage reader, err := h.storage.Retrieve(attachment.FilePath) if err != nil { return fmt.Errorf("failed to retrieve file: %w", err) } defer reader.Close() // Set appropriate headers w.Header().Set("Content-Type", attachment.ContentType) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", attachment.OriginalName)) // If we know the file size, set Content-Length if attachment.FileSize > 0 { w.Header().Set("Content-Length", fmt.Sprintf("%d", attachment.FileSize)) } // Stream the file to the client _, err = io.Copy(w.(http.ResponseWriter), reader) return err }