feat(macros): align attachment uploads

This commit is contained in:
2026-06-07 10:22:36 +08:00
parent 023ab10b31
commit 168f12070e
11 changed files with 504 additions and 53 deletions
+43 -8
View File
@@ -555,24 +555,59 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
// Reference: Chatwoot send_attachment action
func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) error {
attachmentURL, _ := action.ActionParams["attachment_url"].(string)
if attachmentURL == "" {
return fmt.Errorf("send_attachment action requires 'attachment_url' param")
if attachmentURL != "" {
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), model.DirectUpload{FileURL: attachmentURL, OriginalName: attachmentURL, FileType: "file"})
}
blobID := extractUintParam(action.ActionParams, "blob_id")
if blobID == 0 {
return fmt.Errorf("send_attachment action requires 'blob_id' param")
}
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, blobID).First(&upload).Error; err != nil {
return err
}
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload)
}
// Create a message with attachment content type
senderID := uint(0) // Bot sender
func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) error {
var conversation model.Conversation
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, conversationID).First(&conversation).Error; err != nil {
return err
}
senderType := "agent"
if senderID == 0 {
senderType = "bot"
}
msg := &model.Message{
ConversationID: conversationID,
AccountID: accountID,
InboxID: conversation.InboxID,
SenderID: &senderID,
SenderType: "bot",
Content: attachmentURL,
SenderType: senderType,
Content: "",
ContentType: "file",
MessageType: "outgoing",
Private: false,
Status: "sent",
}
return s.db.DB().WithContext(ctx).Create(msg).Error
return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(msg).Error; err != nil {
return err
}
attachment := &model.Attachment{
MessageID: msg.ID,
AccountID: accountID,
FileType: upload.FileType,
FileURL: upload.FileURL,
ThumbURL: upload.ThumbURL,
FileSize: int(upload.FileSize),
FileName: upload.OriginalName,
}
if attachment.FileType == "" {
attachment.FileType = "file"
}
return tx.Create(attachment).Error
})
}
// handleAddPrivateNote adds a private note (internal comment) to the conversation.
@@ -39,6 +39,8 @@ func setupAutomationTestDB(t *testing.T) *gorm.DB {
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Attachment{},
&model.DirectUpload{},
&model.Team{},
&model.TeamMember{},
&model.SlaPolicy{},
+140 -2
View File
@@ -2,7 +2,9 @@ package automation
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/gochat/gochat/internal/model"
@@ -44,6 +46,7 @@ func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) {
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(&macro, id).Error; err != nil {
return nil, err
}
_ = s.hydrateMacroFiles(ctx, &macro)
return &macro, nil
}
@@ -56,6 +59,7 @@ func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint
First(&macro, id).Error; err != nil {
return nil, err
}
_ = s.hydrateMacroFiles(ctx, &macro)
return &macro, nil
}
@@ -73,6 +77,9 @@ func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID
if err := query.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(&macros).Error; err != nil {
return nil, err
}
for i := range macros {
_ = s.hydrateMacroFiles(ctx, &macros[i])
}
return macros, nil
}
@@ -84,6 +91,9 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
if macro.Actions == nil {
macro.Actions = Actions{}
}
if err := s.normalizeMacroAttachmentActions(ctx, macro.AccountID, &macro.Actions); err != nil {
return err
}
macro.Active = true
// Use Select to force all fields including zero-value bool Active=false.
// Without Select, GORM skips zero-value fields and uses column defaults.
@@ -94,7 +104,10 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
).Create(macro).Error; err != nil {
return err
}
return s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error; err != nil {
return err
}
return s.hydrateMacroFiles(ctx, macro)
}
// Update updates an existing macro.
@@ -109,7 +122,12 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
return nil, err
}
existing.Name = macro.Name
existing.Actions = macro.Actions
if macro.Actions != nil {
if err := s.normalizeMacroAttachmentActions(ctx, accountID, &macro.Actions); err != nil {
return nil, err
}
existing.Actions = macro.Actions
}
existing.Visibility = macro.Visibility
existing.UpdatedByID = macro.UpdatedByID
if err := s.Update(ctx, existing); err != nil {
@@ -118,6 +136,126 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
return s.GetByIDForAccount(ctx, accountID, existing.ID)
}
func (s *MacroService) normalizeMacroAttachmentActions(ctx context.Context, accountID uint, actions *Actions) error {
if actions == nil {
return nil
}
for i := range *actions {
action := &(*actions)[i]
if action.ActionName != "send_attachment" {
continue
}
if action.ActionParams == nil {
return fmt.Errorf("invalid attachment")
}
blobID := firstMacroBlobID(action.ActionParams)
upload, err := s.findMacroUpload(ctx, accountID, blobID)
if err != nil {
return fmt.Errorf("invalid attachment")
}
action.ActionParams["blob_id"] = upload.ID
delete(action.ActionParams, "attachment_url")
}
return nil
}
func firstMacroBlobID(params map[string]interface{}) interface{} {
if value := params["blob_id"]; value != nil {
return value
}
return params["attachment_url"]
}
func (s *MacroService) findMacroUpload(ctx context.Context, accountID uint, value interface{}) (*model.DirectUpload, error) {
switch v := value.(type) {
case string:
if parsed, err := strconv.ParseUint(v, 10, 64); err == nil {
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
}
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND upload_uuid = ?", accountID, v).First(&upload).Error; err != nil {
return nil, err
}
return &upload, nil
case float64:
return s.findMacroUploadByID(ctx, accountID, uint(v))
case int:
return s.findMacroUploadByID(ctx, accountID, uint(v))
case uint:
return s.findMacroUploadByID(ctx, accountID, v)
case json.Number:
parsed, err := strconv.ParseUint(string(v), 10, 64)
if err != nil {
return nil, err
}
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
default:
return nil, gorm.ErrRecordNotFound
}
}
func (s *MacroService) findMacroUploadByID(ctx context.Context, accountID, id uint) (*model.DirectUpload, error) {
var upload model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&upload).Error; err != nil {
return nil, err
}
return &upload, nil
}
func (s *MacroService) hydrateMacroFiles(ctx context.Context, macro *Macro) error {
ids := macroAttachmentBlobIDs(macro.Actions)
if len(ids) == 0 {
macro.Files = nil
return nil
}
var uploads []model.DirectUpload
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", macro.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil {
return err
}
files := make([]MacroFile, 0, len(uploads))
for _, upload := range uploads {
files = append(files, MacroFile{ID: upload.ID, MacroID: macro.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName})
}
macro.Files = files
return nil
}
func macroAttachmentBlobIDs(actions Actions) []uint {
seen := map[uint]bool{}
ids := []uint{}
for _, action := range actions {
if action.ActionName != "send_attachment" {
continue
}
id := macroBlobIDAsUint(action.ActionParams["blob_id"])
if id == 0 || seen[id] {
continue
}
seen[id] = true
ids = append(ids, id)
}
return ids
}
func macroBlobIDAsUint(value interface{}) uint {
switch v := value.(type) {
case uint:
return v
case int:
return uint(v)
case float64:
return uint(v)
case string:
parsed, _ := strconv.ParseUint(v, 10, 64)
return uint(parsed)
case json.Number:
parsed, _ := strconv.ParseUint(string(v), 10, 64)
return uint(parsed)
default:
return 0
}
}
// Delete deletes a macro by ID.
func (s *MacroService) Delete(ctx context.Context, id uint) error {
return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error
+41
View File
@@ -256,3 +256,44 @@ func TestMacroService_ExecuteForDisplayIDsQueuesDurableMacroJob(t *testing.T) {
t.Fatalf("expected macro job to add label: %v", err)
}
}
func TestMacroService_ExecuteForDisplayIDsCreatesAttachmentMessages(t *testing.T) {
dbProvider := setupAutomationTestDBProvider(t)
db := dbProvider.DB()
accountID, userID := seedTestAccount(db, t)
inboxID := seedTestInbox(db, t, accountID)
contactID := seedTestContact(db, t, accountID)
displayID := uint(717)
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil {
t.Fatalf("set display id: %v", err)
}
upload := &model.DirectUpload{UploadUUID: "macro-exec-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "macro.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 456, FileURL: "/uploads/account/1/macro.pdf"}
if err := db.Create(upload).Error; err != nil {
t.Fatalf("create upload: %v", err)
}
macro := &Macro{AccountID: accountID, Name: "file macro", Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "macro-exec-upload"}}}, Visibility: MacroVisibilityGlobal, CreatedByID: userID, UpdatedByID: userID}
svc := NewMacroService(dbProvider)
if err := svc.Create(context.Background(), macro); err != nil {
t.Fatalf("create macro: %v", err)
}
if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil {
t.Fatalf("execute macro: %v", err)
}
var message model.Message
if err := db.Where("conversation_id = ? AND content_type = ?", conversationID, "file").First(&message).Error; err != nil {
t.Fatalf("expected file message: %v", err)
}
if message.SenderID == nil || *message.SenderID != userID {
t.Fatalf("expected macro sender %d, got %#v", userID, message.SenderID)
}
var attachment model.Attachment
if err := db.Where("message_id = ?", message.ID).First(&attachment).Error; err != nil {
t.Fatalf("expected attachment: %v", err)
}
if attachment.FileURL != upload.FileURL || attachment.FileName != upload.OriginalName {
t.Fatalf("unexpected attachment: %#v", attachment)
}
}
+11
View File
@@ -123,10 +123,21 @@ type Macro struct {
UpdatedByID uint `gorm:"index;not null" json:"updated_by_id"`
CreatedBy *model.User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
UpdatedBy *model.User `gorm:"foreignKey:UpdatedByID" json:"updated_by,omitempty"`
Files []MacroFile `gorm:"-" json:"files,omitempty"`
}
func (Macro) TableName() string { return "macros" }
type MacroFile struct {
ID uint `json:"id"`
MacroID uint `json:"macro_id"`
FileType string `json:"file_type"`
AccountID uint `json:"account_id"`
FileURL string `json:"file_url"`
BlobID uint `json:"blob_id"`
Filename string `json:"filename"`
}
// ===========================
// CsatSurveyResponse model
// ===========================
+35 -9
View File
@@ -105,6 +105,10 @@ func (h *MacroHandler) Create(c *gin.Context) {
}
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -154,6 +158,10 @@ func (h *MacroHandler) Update(c *gin.Context) {
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
if svcErr != nil {
if strings.Contains(svcErr.Error(), "invalid attachment") {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
return
}
handleServiceError(c, svcErr)
return
}
@@ -312,9 +320,9 @@ type macroActionRequest struct {
}
type macroRequest struct {
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions []macroActionRequest `json:"actions"`
Name string `json:"name"`
Visibility interface{} `json:"visibility"`
Actions *[]macroActionRequest `json:"actions"`
}
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
@@ -322,13 +330,16 @@ func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
if err := c.ShouldBindJSON(&req); err != nil {
return nil, err
}
actions := make(automation.Actions, 0, len(req.Actions))
for _, actionReq := range req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
var actions automation.Actions
if req.Actions != nil {
actions = make(automation.Actions, 0, len(*req.Actions))
for _, actionReq := range *req.Actions {
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
if err != nil {
return nil, err
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
}
return &automation.Macro{
Name: strings.TrimSpace(req.Name),
@@ -392,6 +403,21 @@ func serializeMacro(macro *automation.Macro) gin.H {
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
}
if len(macro.Files) > 0 {
files := make([]gin.H, 0, len(macro.Files))
for _, file := range macro.Files {
files = append(files, gin.H{
"id": file.ID,
"macro_id": file.MacroID,
"file_type": file.FileType,
"account_id": file.AccountID,
"file_url": file.FileURL,
"blob_id": file.BlobID,
"filename": file.Filename,
})
}
item["files"] = files
}
return item
}
@@ -41,6 +41,7 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
&model.Attachment{}, &model.DirectUpload{},
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
))
s.db = db
@@ -54,6 +55,62 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
s.db.Create(s.user)
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
upload := &model.DirectUpload{
UploadUUID: "macro-upload-signed-id",
AccountID: s.account.ID,
Status: model.DirectUploadStatusPending,
Source: model.DirectUploadSourceAccount,
OriginalName: "avatar.png",
FileType: "image",
MimeType: "image/png",
FileSize: 123,
FileURL: "/uploads/account/1/avatar.png",
ThumbURL: "/uploads/account/1/avatar.png",
}
s.Require().NoError(s.db.Create(upload).Error)
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"send file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["macro-upload-signed-id"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
var resp map[string]interface{}
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
payload := resp["payload"].(map[string]interface{})
actions := payload["actions"].([]interface{})
action := actions[0].(map[string]interface{})
assert.Equal(s.T(), []interface{}{float64(upload.ID)}, action["action_params"])
files := payload["files"].([]interface{})
s.Require().Len(files, 1)
file := files[0].(map[string]interface{})
assert.Equal(s.T(), float64(upload.ID), file["blob_id"])
assert.Equal(s.T(), "avatar.png", file["filename"])
}
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
r := gin.New()
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
c.Set("user_id", float64(s.user.ID))
c.Next()
}, s.handler.Create)
body := []byte(`{"name":"bad file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
}
func (s *MacroHandlerTestSuite) TearDownSuite() {
if s.db != nil {
sqlDB, _ := s.db.DB()
+35 -10
View File
@@ -1,10 +1,12 @@
package v1
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
@@ -29,21 +31,44 @@ func (h *UploadHandler) Upload(c *gin.Context) {
return
}
fileHeader, err := c.FormFile("file")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
return
var result *service.UploadResponse
var svcErr error
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
var req struct {
ExternalURL string `json:"external_url"`
}
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.ExternalURL) == "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUploadFromURL(c.Request.Context(), accountID, strings.TrimSpace(req.ExternalURL))
} else {
fileHeader, err := c.FormFile("attachment")
if err != nil {
fileHeader, err = c.FormFile("file")
}
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
return
}
result, svcErr = h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
}
result, svcErr := h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
FileHeader: fileHeader,
})
if svcErr != nil {
handleServiceError(c, svcErr)
status := http.StatusUnprocessableEntity
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": svcErr.Error()})
return
}
response.OK(c, result)
c.JSON(http.StatusOK, gin.H{
"file_url": result.FileURL,
"blob_id": result.UploadUUID,
"blob_key": result.UploadUUID,
})
}
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
+60 -2
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -55,9 +56,13 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
// makeMultipartUploadBody creates a multipart form body with a file field.
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
return makeMultipartUploadBodyWithField("file", filename, content)
}
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
body = &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, "", err
}
@@ -79,7 +84,60 @@ func TestUploadHandler_Upload_NoFile(t *testing.T) {
// Set account_id in context (simulating middleware)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
}
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
require.NoError(t, err)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
assert.Equal(t, payload["blob_id"], payload["blob_key"])
}
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
tmpDir := t.TempDir()
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte("external image"))
}))
defer server.Close()
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
assert.NotContains(t, payload, "success")
assert.NotEmpty(t, payload["file_url"])
assert.NotEmpty(t, payload["blob_id"])
}
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
+72 -18
View File
@@ -1,6 +1,7 @@
package service
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -9,6 +10,8 @@ import (
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
@@ -134,6 +137,48 @@ func (s *UploadService) AccountUpload(ctx context.Context, accountID uint, req A
return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount)
}
func (s *UploadService) AccountUploadFromURL(ctx context.Context, accountID uint, externalURL string) (*UploadResponse, error) {
if accountID == 0 {
return nil, errors.New("account_id is required")
}
parsed, err := url.ParseRequestURI(externalURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, externalURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch external url: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("failed to fetch external url: status %d", resp.StatusCode)
}
maxSize := int64(s.cfg.Storage.MaxFileSize)
if maxSize <= 0 {
maxSize = 50 << 20
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
if err != nil {
return nil, err
}
if int64(len(data)) > maxSize {
return nil, errors.New("file too large")
}
filename := filepath.Base(parsed.Path)
if filename == "." || filename == "/" || filename == "" {
filename = "upload"
}
contentType := resp.Header.Get("Content-Type")
if idx := strings.Index(contentType, ";"); idx >= 0 {
contentType = strings.TrimSpace(contentType[:idx])
}
return s.processUploadContent(ctx, accountID, model.DirectUploadSourceAccount, filename, contentType, int64(len(data)), bytes.NewReader(data))
}
// --- Account Direct Upload (staged for message attachment) ---
// AccountDirectUpload handles a staged file upload from the dashboard (account-scoped).
@@ -372,12 +417,20 @@ func (s *UploadService) createActiveStorageDirectUpload(ctx context.Context, acc
}
func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) {
// Step 1: Validate file
mimeType := fileHeader.Header.Get("Content-Type")
src, err := fileHeader.Open()
if err != nil {
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.processUploadContent(ctx, accountID, source, fileHeader.Filename, mimeType, fileHeader.Size, src)
}
func (s *UploadService) processUploadContent(ctx context.Context, accountID uint, source model.DirectUploadSource, filename, mimeType string, size int64, reader io.Reader) (*UploadResponse, error) {
if mimeType == "" || mimeType == "application/octet-stream" {
// Fall back to filename-based detection when Content-Type is empty
// or the generic default (browsers/multipart forms often send this).
detected := detectUploadMIMEFromFilename(fileHeader.Filename)
detected := detectUploadMIMEFromFilename(filename)
if detected != "" && detected != "application/octet-stream" {
mimeType = detected
}
@@ -396,13 +449,13 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
if maxSize == 0 {
maxSize = int64(s.cfg.Storage.MaxFileSize)
}
if fileHeader.Size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory)
if size > maxSize {
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", size, maxSize, fileCategory)
}
// Step 2: Store file to disk
ext := filepath.Ext(fileHeader.Filename)
fileURL, thumbURL, err := s.saveFileToDisk(accountID, source, ext, fileHeader)
ext := filepath.Ext(filename)
fileURL, thumbURL, err := s.saveUploadReader(accountID, source, ext, mimeType, reader)
if err != nil {
return nil, fmt.Errorf("failed to save file: %w", err)
}
@@ -415,10 +468,10 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
AccountID: accountID,
Status: model.DirectUploadStatusPending,
Source: source,
OriginalName: fileHeader.Filename,
OriginalName: filename,
FileType: fileCategory,
MimeType: mimeType,
FileSize: fileHeader.Size,
FileSize: size,
FileURL: fileURL,
ThumbURL: thumbURL,
ExpiresAt: time.Now().Add(expiryDuration),
@@ -431,7 +484,7 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
}
applogger.L().Infof("Direct file upload: account=%d source=%s uuid=%s file=%s size=%d",
accountID, source, uploadUUID, fileHeader.Filename, fileHeader.Size)
accountID, source, uploadUUID, filename, size)
return &UploadResponse{
UploadID: upload.ID,
@@ -448,6 +501,15 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
}
func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUploadSource, ext string, fileHeader *multipart.FileHeader) (string, string, error) {
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
return s.saveUploadReader(accountID, source, ext, fileHeader.Header.Get("Content-Type"), src)
}
func (s *UploadService) saveUploadReader(accountID uint, source model.DirectUploadSource, ext, mimeType string, reader io.Reader) (string, string, error) {
localPath := s.cfg.Storage.LocalPath
if localPath == "" {
localPath = "./uploads"
@@ -464,13 +526,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
fileName := baseName + ext
fullPath := filepath.Join(dirPath, fileName)
// Open uploaded file
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
}
defer src.Close()
// Create destination file
dst, err := os.Create(fullPath)
if err != nil {
@@ -479,7 +534,7 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
defer dst.Close()
// Copy file content
if _, err := io.Copy(dst, src); err != nil {
if _, err := io.Copy(dst, reader); err != nil {
os.Remove(fullPath) // Clean up on failure
return "", "", fmt.Errorf("failed to copy file content: %w", err)
}
@@ -488,7 +543,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
thumbURL := ""
// For images, we reference the same path (thumbnail generation can be added later)
mimeType := fileHeader.Header.Get("Content-Type")
if strings.HasPrefix(mimeType, "image/") {
thumbURL = fileURL // Placeholder: same as fileURL for now
}