Files
openaccounting-server/core/api/attachment_storage.go
Aaron Guise 8b6ba74ce9 feat: implement secure file upload system with JWT authentication
- Add JWT-based secure file access for local storage with 1-hour expiry
- Implement GORM repository methods for attachment CRUD operations
- Add secure file serving endpoint with token validation
- Update storage interface to support user context in URL generation
- Add comprehensive security features including path traversal protection
- Update documentation with security model and configuration examples
- Add utility functions for hex/byte conversion and UUID validation
- Configure secure file permissions (0600) for uploaded files

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-03 15:45:25 +12:00

289 lines
8.6 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) with user context for JWT tokens
url, err := attachmentHandler.storage.GetURLWithContext(attachment.FilePath, time.Hour, user.Id, attachment.OrgId)
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
}