You've already forked openaccounting-server
forked from cybercinch/openaccounting-server
feat: implement unified S3-compatible storage system
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>
This commit is contained in:
289
core/api/attachment_storage.go
Normal file
289
core/api/attachment_storage.go
Normal file
@@ -0,0 +1,289 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user