You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
Consolidates storage backends into a single S3-compatible driver that supports: - AWS S3 (native) - Backblaze B2 (S3-compatible API) - Cloudflare R2 (S3-compatible API) - MinIO and other S3-compatible services - Local filesystem for development This replaces the previous separate B2 driver with a unified approach, reducing dependencies and complexity while adding support for more services. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
289 lines
8.5 KiB
Go
289 lines
8.5 KiB
Go
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
|
|
} |