diff --git a/cmd/media.go b/cmd/media.go index 147f708..4c68960 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -16,6 +16,7 @@ import ( "github.com/abhinavxd/libredesk/internal/stringutil" "github.com/google/uuid" "github.com/valyala/fasthttp" + "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" ) @@ -50,10 +51,10 @@ func handleMediaUpload(r *fastglue.Request) error { defer file.Close() // Inline? - var disposition = attachment.DispositionAttachment + var disposition = null.StringFrom(attachment.DispositionAttachment) inline, ok := form.Value["inline"] if ok && len(inline) > 0 && inline[0] == "true" { - disposition = attachment.DispositionInline + disposition = null.StringFrom(attachment.DispositionInline) } // Linked model? @@ -94,11 +95,11 @@ func handleMediaUpload(r *fastglue.Request) error { } }() - // Generate and upload thumbnail and save it's dimensions if it's an image. + // Generate and upload thumbnail and store image dimensions in the media meta. var meta = []byte("{}") if slices.Contains(image.Exts, srcExt) { file.Seek(0, 0) - thumbFile, err := image.CreateThumb(thumbnailSize, file) + thumbFile, err := image.CreateThumb(image.DefThumbSize, file) if err != nil { app.lo.Error("error creating thumb image", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error creating image thumbnail", nil, envelope.GeneralError) @@ -121,7 +122,6 @@ func handleMediaUpload(r *fastglue.Request) error { "width": width, "height": height, }) - } file.Seek(0, 0) @@ -133,7 +133,7 @@ func handleMediaUpload(r *fastglue.Request) error { } // Insert in DB. - media, err := app.media.Insert(srcFileName, srcContentType, "" /**content_id**/, linkedModel, disposition, uuid.String(), 0, int(srcFileSize), meta) + media, err := app.media.Insert(disposition, srcFileName, srcContentType, "" /**content_id**/, linkedModel, uuid.String(), 0, int(srcFileSize), meta) if err != nil { cleanUp = true app.lo.Error("error inserting metadata into database", "error", err) diff --git a/cmd/users.go b/cmd/users.go index 93651a9..71c10e2 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -17,6 +17,7 @@ import ( tmpl "github.com/abhinavxd/libredesk/internal/template" "github.com/abhinavxd/libredesk/internal/user/models" "github.com/valyala/fasthttp" + "github.com/volatiletech/null/v9" "github.com/zerodha/fastglue" ) @@ -141,7 +142,7 @@ func handleUpdateCurrentUser(r *fastglue.Request) error { // Reset ptr. file.Seek(0, 0) - media, err := app.media.UploadAndInsert(srcFileName, srcContentType, "", mmodels.ModelUser, user.ID, file, int(srcFileSize), "", []byte("{}")) + media, err := app.media.UploadAndInsert(srcFileName, srcContentType, "" /**content_id**/, mmodels.ModelUser, user.ID, file, int(srcFileSize), null.NewString("", false) /**disposition**/, []byte("{}") /**meta**/) if err != nil { app.lo.Error("error uploading file", "error", err) return r.SendErrorEnvelope(fasthttp.StatusInternalServerError, "Error uploading file", nil, envelope.GeneralError) @@ -188,7 +189,7 @@ func handleCreateUser(r *fastglue.Request) error { return r.SendErrorEnvelope(fasthttp.StatusBadRequest, "Empty `first_name`", nil, envelope.InputError) } - // Right now, only agents can be created, can be named better. + // Right now, only agents can be created. if err := app.user.CreateAgent(&user); err != nil { return sendErrorEnvelope(r, err) } diff --git a/internal/conversation/conversation.go b/internal/conversation/conversation.go index 8455d6d..cffd9ba 100644 --- a/internal/conversation/conversation.go +++ b/internal/conversation/conversation.go @@ -32,6 +32,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/go-i18n" "github.com/lib/pq" + "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -100,7 +101,8 @@ type mediaStore interface { Attach(id int, model string, modelID int) error GetByModel(id int, model string) ([]mmodels.Media, error) ContentIDExists(contentID string) (bool, error) - UploadAndInsert(fileName, contentType, contentID, modelType string, modelID int, content io.ReadSeeker, fileSize int, disposition string, meta []byte) (mmodels.Media, error) + Upload(fileName, contentType string, content io.ReadSeeker) (string, error) + UploadAndInsert(fileName, contentType, contentID, modelType string, modelID int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (mmodels.Media, error) } type inboxStore interface { diff --git a/internal/conversation/message.go b/internal/conversation/message.go index 3c8bf09..b697298 100644 --- a/internal/conversation/message.go +++ b/internal/conversation/message.go @@ -7,17 +7,22 @@ import ( "encoding/json" "errors" "fmt" + "path/filepath" + "slices" + "strings" "time" "github.com/abhinavxd/libredesk/internal/attachment" amodels "github.com/abhinavxd/libredesk/internal/automation/models" "github.com/abhinavxd/libredesk/internal/conversation/models" "github.com/abhinavxd/libredesk/internal/envelope" + "github.com/abhinavxd/libredesk/internal/image" "github.com/abhinavxd/libredesk/internal/inbox" mmodels "github.com/abhinavxd/libredesk/internal/media/models" "github.com/abhinavxd/libredesk/internal/stringutil" umodels "github.com/abhinavxd/libredesk/internal/user/models" "github.com/lib/pq" + "github.com/volatiletech/null/v9" ) const ( @@ -460,16 +465,14 @@ func (m *Manager) getMessageActivityContent(activityType, newValue, actorName st // conversations, and creates a new conversation if necessary. It also // inserts the message, uploads any attachments, and queues the conversation evaluation of automation rules. func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { - var err error - - // Find or create contact and set sender ID. - if err = m.userStore.CreateContact(&in.Contact); err != nil { + // Find or create contact and set sender ID in message. + if err := m.userStore.CreateContact(&in.Contact); err != nil { m.lo.Error("error upserting contact", "error", err) return err } in.Message.SenderID = in.Contact.ID - // This message already exists? + // Conversations exists for this message? conversationID, err := m.findConversationID([]string{in.Message.SourceID.String}) if err != nil && err != ErrConversationNotFound { return err @@ -489,9 +492,10 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { return err } - // Upload attachments. + // Upload message attachments. if err := m.uploadMessageAttachments(&in.Message); err != nil { - return err + // Log error but continue processing. + m.lo.Error("error uploading message attachments", "message_source_id", in.Message.SourceID, "error", err) } // Evaluate automation rules for this conversation. @@ -499,7 +503,6 @@ func (m *Manager) processIncomingMessage(in models.IncomingMessage) error { m.automation.EvaluateNewConversationRules(in.Message.ConversationUUID) } else { m.automation.EvaluateConversationUpdateRules(in.Message.ConversationUUID, amodels.EventConversationMessageIncoming) - // Reopen conversation if it's closed, snoozed, or resolved. systemUser, err := m.userStore.GetSystemUser() if err != nil { @@ -581,29 +584,32 @@ func (c *Manager) generateMessagesQuery(baseQuery string, qArgs []interface{}, p } // uploadMessageAttachments uploads attachments for a message. -func (m *Manager) uploadMessageAttachments(message *models.Message) error { +func (m *Manager) uploadMessageAttachments(message *models.Message) []error { if len(message.Attachments) == 0 { return nil } - var uploadErr error + var uploadErr []error for _, attachment := range message.Attachments { - // Check if this attachment already exists by content ID. - exists, err := m.mediaStore.ContentIDExists(attachment.ContentID) - if err != nil { - m.lo.Error("error checking media existence", "error", err) - continue + // Check if this attachment already exists by the content ID. + if attachment.ContentID != "" { + exists, err := m.mediaStore.ContentIDExists(attachment.ContentID) + if err != nil { + m.lo.Error("error checking media existence by content ID", "content_id", attachment.ContentID, "error", err) + continue + } + if exists { + m.lo.Debug("attachment with content ID already exists", "content_id", attachment.ContentID) + continue + } } - if exists { - m.lo.Debug("attachment already exists", "content_id", attachment.ContentID) - continue - } + m.lo.Debug("uploading message attachment", "name", attachment.Name, "content_id", attachment.ContentID, "size", attachment.Size) - m.lo.Debug("uploading message attachment", "name", attachment.Name) + // Sanitize filename and upload. attachment.Name = stringutil.SanitizeFilename(attachment.Name) reader := bytes.NewReader(attachment.Content) - _, err = m.mediaStore.UploadAndInsert( + media, err := m.mediaStore.UploadAndInsert( attachment.Name, attachment.ContentType, attachment.ContentID, @@ -611,14 +617,23 @@ func (m *Manager) uploadMessageAttachments(message *models.Message) error { message.ID, reader, attachment.Size, - attachment.Disposition, + null.StringFrom(attachment.Disposition), []byte("{}"), ) - if err != nil { - uploadErr = err + uploadErr = append(uploadErr, err) m.lo.Error("failed to upload attachment", "name", attachment.Name, "error", err) } + + // If the attachment is an image, generate and upload thumbnail. + attachmentExt := strings.TrimPrefix(strings.ToLower(filepath.Ext(attachment.Name)), ".") + if slices.Contains(image.Exts, attachmentExt) { + if err := m.uploadThumbnailForMedia(media, attachment.Content); err != nil { + uploadErr = append(uploadErr, err) + m.lo.Error("error uploading thumbnail", "error", err) + } + } + } return uploadErr } @@ -704,7 +719,7 @@ func (m *Manager) attachAttachmentsToMessage(message *models.Message) error { attachment := attachment.Attachment{ Name: media.Filename, Content: blob, - Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition), + Header: attachment.MakeHeader(media.ContentType, media.UUID, media.Filename, "base64", media.Disposition.String), } attachments = append(attachments, attachment) } @@ -726,3 +741,28 @@ func (m *Manager) getOutgoingProcessingMessageIDs() []int { }) return out } + +// uploadThumbnailForMedia prepares and uploads a thumbnail for an image attachment. +func (m *Manager) uploadThumbnailForMedia(media mmodels.Media, content []byte) error { + // Create a reader from the content + file := bytes.NewReader(content) + + // Seek to the beginning of the file + file.Seek(0, 0) + + // Create the thumbnail + thumbFile, err := image.CreateThumb(image.DefThumbSize, file) + if err != nil { + return fmt.Errorf("error creating thumbnail: %w", err) + } + + // Generate thumbnail name + thumbName := fmt.Sprintf("thumb_%s", media.UUID) + + // Upload the thumbnail + if _, err := m.mediaStore.Upload(thumbName, media.ContentType, thumbFile); err != nil { + m.lo.Error("error uploading thumbnail", "error", err) + return fmt.Errorf("error uploading thumbnail: %w", err) + } + return nil +} diff --git a/internal/image/image.go b/internal/image/image.go index 6a9102c..ddb9f87 100644 --- a/internal/image/image.go +++ b/internal/image/image.go @@ -10,7 +10,8 @@ import ( ) var ( - Exts = []string{"gif", "png", "jpg", "jpeg"} + Exts = []string{"gif", "png", "jpg", "jpeg"} + DefThumbSize = 150 ) // GetDimensions returns the width and height of the image in the provided file. diff --git a/internal/inbox/channel/email/imap.go b/internal/inbox/channel/email/imap.go index bc9cb25..35f286d 100644 --- a/internal/inbox/channel/email/imap.go +++ b/internal/inbox/channel/email/imap.go @@ -232,6 +232,7 @@ func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, inc Name: att.FileName, Content: att.Content, ContentType: att.ContentType, + ContentID: att.ContentID, Size: len(att.Content), Disposition: attachment.DispositionAttachment, }) diff --git a/internal/media/media.go b/internal/media/media.go index 3cd6a01..599fca2 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -15,6 +15,7 @@ import ( "github.com/abhinavxd/libredesk/internal/media/models" "github.com/google/uuid" "github.com/jmoiron/sqlx" + "github.com/volatiletech/null/v9" "github.com/zerodha/logf" ) @@ -72,14 +73,14 @@ type queries struct { } // UploadAndInsert uploads file on storage and inserts an entry in db. -func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID, modelType string, modelID int, content io.ReadSeeker, fileSize int, disposition string, meta []byte) (models.Media, error) { +func (m *Manager) UploadAndInsert(srcFilename, contentType, contentID, modelType string, modelID int, content io.ReadSeeker, fileSize int, disposition null.String, meta []byte) (models.Media, error) { var uuid = uuid.New() _, err := m.Upload(uuid.String(), contentType, content) if err != nil { return models.Media{}, err } - media, err := m.Insert(srcFilename, contentType, contentID, modelType, disposition, uuid.String(), modelID, fileSize, meta) + media, err := m.Insert(disposition, srcFilename, contentType, contentID, modelType, uuid.String(), modelID, fileSize, meta) if err != nil { m.store.Delete(uuid.String()) return models.Media{}, err @@ -98,7 +99,7 @@ func (m *Manager) Upload(fileName, contentType string, content io.ReadSeeker) (s } // Insert inserts media details into the database and returns the inserted media record. -func (m *Manager) Insert(fileName, contentType, contentID, modelType, disposition, uuid string, modelID int, fileSize int, meta []byte) (models.Media, error) { +func (m *Manager) Insert(disposition null.String, fileName, contentType, contentID, modelType, uuid string, modelID int, fileSize int, meta []byte) (models.Media, error) { var id int if err := m.queries.Insert.QueryRow(m.store.Name(), fileName, contentType, fileSize, meta, modelID, modelType, disposition, contentID, uuid).Scan(&id); err != nil { m.lo.Error("error inserting media", "error", err) @@ -184,19 +185,6 @@ func (m *Manager) Delete(name string) error { return nil } -// Delete deletes a media file from both the storage backend and the database. -func (m *Manager) DeleteByUUID(uuid string) error { - if err := m.store.Delete(uuid); err != nil { - m.lo.Error("error deleting media from store", "error", err) - return envelope.NewError(envelope.GeneralError, "Error deleting media from store", nil) - } - if _, err := m.queries.Delete.Exec(uuid); err != nil { - m.lo.Error("error deleting media from db", "error", err) - return envelope.NewError(envelope.GeneralError, "Error deleting media from DB", nil) - } - return nil -} - // DeleteUnlinkedMedia is a blocking function that periodically deletes media files that are not linked to any conversation message. func (m *Manager) DeleteUnlinkedMedia(ctx context.Context) { m.deleteUnlinkedMessageMedia() @@ -221,7 +209,7 @@ func (m *Manager) deleteUnlinkedMessageMedia() error { return err } for _, mm := range media { - if err := m.DeleteByUUID(mm.UUID); err != nil { + if err := m.Delete(mm.UUID); err != nil { return err } } diff --git a/internal/media/models/models.go b/internal/media/models/models.go index 782397e..f4a0573 100644 --- a/internal/media/models/models.go +++ b/internal/media/models/models.go @@ -24,7 +24,7 @@ type Media struct { ModelID null.Int `db:"model_id" json:"-"` Size int `db:"size" json:"size"` Store string `db:"store" json:"store"` - Disposition string `db:"disposition" json:"disposition"` + Disposition null.String `db:"disposition" json:"disposition"` URL string `json:"url"` ContentID string `json:"-"` Content []byte `json:"-"`