From 3b9a0f782e62713ef230344a12558da5f2350eec Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Thu, 18 May 2023 16:55:59 +0530 Subject: [PATCH] Add support for file attachments on campaigns (#1341) - Adds support for arbitrary file uploads with an admin setting to select allowed file extensions. - Adds support for attaching media (files) to campaigns. --- cmd/campaigns.go | 11 +++- cmd/init.go | 23 ++++--- cmd/install.go | 1 + cmd/manager_store.go | 69 ++++++++++++++------ cmd/media.go | 80 +++++++++++++----------- cmd/public.go | 2 +- cmd/settings.go | 4 ++ cmd/tx.go | 3 +- frontend/cypress/e2e/campaigns.cy.js | 37 ++++++++++- frontend/src/assets/style.scss | 42 ++++++++++++- frontend/src/components/Editor.vue | 2 +- frontend/src/views/Campaign.vue | 90 ++++++++++++++++++++++----- frontend/src/views/Campaigns.vue | 1 + frontend/src/views/Media.vue | 19 ++++-- frontend/src/views/settings/media.vue | 25 ++++++-- go.mod | 1 + go.sum | 2 + i18n/en.json | 4 ++ internal/core/campaigns.go | 8 ++- internal/core/media.go | 14 +++-- internal/manager/manager.go | 34 +++++++++- internal/media/media.go | 19 +++--- internal/migrations/v2.5.0.go | 31 ++++++++- models/models.go | 8 +++ models/settings.go | 27 ++++---- queries.sql | 44 ++++++++++--- schema.sql | 17 +++++ 27 files changed, 478 insertions(+), 140 deletions(-) diff --git a/cmd/campaigns.go b/cmd/campaigns.go index ad6b6ec..0191839 100644 --- a/cmd/campaigns.go +++ b/cmd/campaigns.go @@ -32,6 +32,8 @@ type campaignReq struct { // to the outside world. ListIDs []int `json:"lists"` + MediaIDs []int `json:"media"` + // This is only relevant to campaign test requests. SubscriberEmails pq.StringArray `json:"subscribers"` } @@ -220,7 +222,7 @@ func handleCreateCampaign(c echo.Context) error { o.ArchiveTemplateID = o.TemplateID } - out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs) + out, err := app.core.CreateCampaign(o.Campaign, o.ListIDs, o.MediaIDs) if err != nil { return err } @@ -264,7 +266,7 @@ func handleUpdateCampaign(c echo.Context) error { o = c } - out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.SendLater) + out, err := app.core.UpdateCampaign(id, o.Campaign, o.ListIDs, o.MediaIDs, o.SendLater) if err != nil { return err } @@ -437,6 +439,11 @@ func handleTestCampaign(c echo.Context) error { camp.ContentType = req.ContentType camp.Headers = req.Headers camp.TemplateID = req.TemplateID + for _, id := range req.MediaIDs { + if id > 0 { + camp.MediaIDs = append(camp.MediaIDs, int64(id)) + } + } // Send the test messages. for _, s := range subs { diff --git a/cmd/init.go b/cmd/init.go index 830804c..f88542f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -86,13 +86,17 @@ type constants struct { PublicJS []byte `koanf:"public.custom_js"` } - UnsubURL string - LinkTrackURL string - ViewTrackURL string - OptinURL string - MessageURL string - ArchiveURL string - MediaProvider string + UnsubURL string + LinkTrackURL string + ViewTrackURL string + OptinURL string + MessageURL string + ArchiveURL string + + MediaUpload struct { + Provider string + Extensions []string + } BounceWebhooksEnabled bool BounceSESEnabled bool @@ -368,7 +372,8 @@ func initConstants() *constants { c.RootURL = strings.TrimRight(c.RootURL, "/") c.Lang = ko.String("app.lang") c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) - c.MediaProvider = ko.String("upload.provider") + c.MediaUpload.Provider = ko.String("upload.provider") + c.MediaUpload.Extensions = ko.Strings("upload.extensions") c.Privacy.DomainBlocklist = ko.Strings("privacy.domain_blocklist") // Static URLS. @@ -448,7 +453,7 @@ func initCampaignManager(q *models.Queries, cs *constants, app *App) *manager.Ma SlidingWindowRate: ko.Int("app.message_sliding_window_rate"), ScanInterval: time.Second * 5, ScanCampaigns: !ko.Bool("passive"), - }, newManagerStore(q), campNotifCB, app.i18n, lo) + }, newManagerStore(q, app.core, app.media), campNotifCB, app.i18n, lo) } func initTxTemplates(m *manager.Manager, app *App) { diff --git a/cmd/install.go b/cmd/install.go index 5c70d94..5e39a87 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -161,6 +161,7 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo false, archiveTplID, `{"name": "Subscriber"}`, + nil, ); err != nil { lo.Fatalf("error creating sample campaign: %v", err) } diff --git a/cmd/manager_store.go b/cmd/manager_store.go index a6f22f0..91bd7fb 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -1,27 +1,37 @@ package main import ( + "net/http" + "github.com/gofrs/uuid" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/manager" + "github.com/knadh/listmonk/internal/media" "github.com/knadh/listmonk/models" "github.com/lib/pq" ) -// runnerDB implements runner.DataSource over the primary +// store implements DataSource over the primary // database. -type runnerDB struct { +type store struct { queries *models.Queries + core *core.Core + media media.Store + h *http.Client } -func newManagerStore(q *models.Queries) *runnerDB { - return &runnerDB{ +func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *store { + return &store{ queries: q, + core: c, + media: m, } } // NextCampaigns retrieves active campaigns ready to be processed. -func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) { +func (s *store) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) { var out []*models.Campaign - err := r.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs)) + err := s.queries.NextCampaigns.Select(&out, pq.Int64Array(excludeIDs)) return out, err } @@ -29,27 +39,46 @@ func (r *runnerDB) NextCampaigns(excludeIDs []int64) ([]*models.Campaign, error) // Since batches are processed sequentially, the retrieval is ordered by ID, // and every batch takes the last ID of the last batch and fetches the next // batch above that. -func (r *runnerDB) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { +func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { var out []models.Subscriber - err := r.queries.NextCampaignSubscribers.Select(&out, campID, limit) + err := s.queries.NextCampaignSubscribers.Select(&out, campID, limit) return out, err } // GetCampaign fetches a campaign from the database. -func (r *runnerDB) GetCampaign(campID int) (*models.Campaign, error) { +func (s *store) GetCampaign(campID int) (*models.Campaign, error) { var out = &models.Campaign{} - err := r.queries.GetCampaign.Get(out, campID, nil, "default") + err := s.queries.GetCampaign.Get(out, campID, nil, "default") return out, err } // UpdateCampaignStatus updates a campaign's status. -func (r *runnerDB) UpdateCampaignStatus(campID int, status string) error { - _, err := r.queries.UpdateCampaignStatus.Exec(campID, status) +func (s *store) UpdateCampaignStatus(campID int, status string) error { + _, err := s.queries.UpdateCampaignStatus.Exec(campID, status) return err } +// GetAttachment fetches a media attachment blob. +func (s *store) GetAttachment(mediaID int) (models.Attachment, error) { + m, err := s.core.GetMedia(mediaID, "", s.media) + if err != nil { + return models.Attachment{}, err + } + + b, err := s.media.GetBlob(m.URL) + if err != nil { + return models.Attachment{}, err + } + + return models.Attachment{ + Name: m.Filename, + Content: b, + Header: manager.MakeAttachmentHeader(m.Filename, "base64", m.ContentType), + }, nil +} + // CreateLink registers a URL with a UUID for tracking clicks and returns the UUID. -func (r *runnerDB) CreateLink(url string) (string, error) { +func (s *store) CreateLink(url string) (string, error) { // Create a new UUID for the URL. If the URL already exists in the DB // the UUID in the database is returned. uu, err := uuid.NewV4() @@ -58,7 +87,7 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } var out string - if err := r.queries.CreateLink.Get(&out, uu, url); err != nil { + if err := s.queries.CreateLink.Get(&out, uu, url); err != nil { return "", err } @@ -66,13 +95,13 @@ func (r *runnerDB) CreateLink(url string) (string, error) { } // RecordBounce records a bounce event and returns the bounce count. -func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { +func (s *store) RecordBounce(b models.Bounce) (int64, int, error) { var res = struct { SubscriberID int64 `db:"subscriber_id"` Num int `db:"num"` }{} - err := r.queries.UpdateCampaignStatus.Select(&res, + err := s.queries.UpdateCampaignStatus.Select(&res, b.SubscriberUUID, b.Email, b.CampaignUUID, @@ -83,12 +112,12 @@ func (r *runnerDB) RecordBounce(b models.Bounce) (int64, int, error) { return res.SubscriberID, res.Num, err } -func (r *runnerDB) BlocklistSubscriber(id int64) error { - _, err := r.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) +func (s *store) BlocklistSubscriber(id int64) error { + _, err := s.queries.BlocklistSubscribers.Exec(pq.Int64Array{id}) return err } -func (r *runnerDB) DeleteSubscriber(id int64) error { - _, err := r.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) +func (s *store) DeleteSubscriber(id int64) error { + _, err := s.queries.DeleteSubscribers.Exec(pq.Int64Array{id}) return err } diff --git a/cmd/media.go b/cmd/media.go index 13a03f8..12e1632 100644 --- a/cmd/media.go +++ b/cmd/media.go @@ -15,16 +15,12 @@ import ( const ( thumbPrefix = "thumb_" - thumbnailSize = 120 + thumbnailSize = 250 ) -// validMimes is the list of image types allowed to be uploaded. var ( - validMimes = []string{"image/jpg", "image/jpeg", "image/png", "image/gif", "image/svg+xml"} - validExts = []string{".jpg", ".jpeg", ".png", ".gif", ".svg"} - - // Vector extensions that don't need to be resized for thumbnails. - vectorExts = []string{".svg"} + vectorExts = []string{"svg"} + imageExts = []string{"gif", "png", "jpg", "jpeg"} ) // handleUploadMedia handles media file uploads. @@ -39,23 +35,6 @@ func handleUploadMedia(c echo.Context) error { app.i18n.Ts("media.invalidFile", "error", err.Error())) } - // Validate file extension. - ext := strings.ToLower(filepath.Ext(file.Filename)) - if ok := inArray(ext, validExts); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", ext)) - } - - // Validate file's mime. - typ := file.Header.Get("Content-type") - if ok := inArray(typ, validMimes); !ok { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("media.unsupportedFileType", "type", typ)) - } - - // Generate filename - fName := makeFilename(file.Filename) - // Read file contents in memory src, err := file.Open() if err != nil { @@ -64,8 +43,23 @@ func handleUploadMedia(c echo.Context) error { } defer src.Close() + var ( + // Naive check for content type and extension. + ext = strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), ".") + contentType = file.Header.Get("Content-Type") + ) + + // Validate file extension. + if !inArray("*", app.constants.MediaUpload.Extensions) { + if ok := inArray(ext, app.constants.MediaUpload.Extensions); !ok { + return echo.NewHTTPError(http.StatusBadRequest, + app.i18n.Ts("media.unsupportedFileType", "type", ext)) + } + } + // Upload the file. - fName, err = app.media.Put(fName, typ, src) + fName := makeFilename(file.Filename) + fName, err = app.media.Put(fName, contentType, src) if err != nil { app.log.Printf("error uploading file: %v", err) cleanUp = true @@ -73,22 +67,26 @@ func handleUploadMedia(c echo.Context) error { app.i18n.Ts("media.errorUploading", "error", err.Error())) } + var ( + thumbfName = "" + width = 0 + height = 0 + ) defer func() { // If any of the subroutines in this function fail, // the uploaded image should be removed. if cleanUp { app.media.Delete(fName) - app.media.Delete(thumbPrefix + fName) + + if thumbfName != "" { + app.media.Delete(thumbfName) + } } }() // Create thumbnail from file for non-vector formats. - var ( - thumbfName = fName - width = 0 - height = 0 - ) - if !inArray(ext, vectorExts) { + isImage := inArray(ext, imageExts) + if isImage { thumbFile, w, h, err := processImage(file) if err != nil { cleanUp = true @@ -100,7 +98,7 @@ func handleUploadMedia(c echo.Context) error { height = h // Upload thumbnail. - tf, err := app.media.Put(thumbPrefix+fName, typ, thumbFile) + tf, err := app.media.Put(thumbPrefix+fName, contentType, thumbFile) if err != nil { cleanUp = true app.log.Printf("error saving thumbnail: %v", err) @@ -109,13 +107,19 @@ func handleUploadMedia(c echo.Context) error { } thumbfName = tf } + if inArray(ext, vectorExts) { + thumbfName = fName + } // Write to the DB. - meta := models.JSON{ - "width": width, - "height": height, + meta := models.JSON{} + if isImage { + meta = models.JSON{ + "width": width, + "height": height, + } } - m, err := app.core.InsertMedia(fName, thumbfName, meta, app.constants.MediaProvider, app.media) + m, err := app.core.InsertMedia(fName, thumbfName, contentType, meta, app.constants.MediaUpload.Provider, app.media) if err != nil { cleanUp = true return err @@ -139,7 +143,7 @@ func handleGetMedia(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } - out, err := app.core.GetAllMedia(app.constants.MediaProvider, app.media) + out, err := app.core.GetAllMedia(app.constants.MediaUpload.Provider, app.media) if err != nil { return err } diff --git a/cmd/public.go b/cmd/public.go index 746d182..652aba9 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -576,7 +576,7 @@ func handleSelfExportSubscriberData(c echo.Context) error { { Name: fname, Content: b, - Header: manager.MakeAttachmentHeader(fname, "base64"), + Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"), }, }, }); err != nil { diff --git a/cmd/settings.go b/cmd/settings.go index 563038e..d048517 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -162,6 +162,10 @@ func handleUpdateSettings(c echo.Context) error { set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret } + for n, v := range set.UploadExtensions { + set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), ".")) + } + // Domain blocklist. doms := make([]string, 0) for _, d := range set.DomainBlocklist { diff --git a/cmd/tx.go b/cmd/tx.go index a54b946..7b4c869 100644 --- a/cmd/tx.go +++ b/cmd/tx.go @@ -57,10 +57,11 @@ func handleSendTxMessage(c echo.Context) error { m.Attachments = append(m.Attachments, models.Attachment{ Name: f.Filename, - Header: manager.MakeAttachmentHeader(f.Filename, "base64"), + Header: manager.MakeAttachmentHeader(f.Filename, "base64", f.Header.Get("Content-Type")), Content: b, }) } + } else if err := c.Bind(&m); err != nil { return err } diff --git a/frontend/cypress/e2e/campaigns.cy.js b/frontend/cypress/e2e/campaigns.cy.js index f57ca11..671f82d 100644 --- a/frontend/cypress/e2e/campaigns.cy.js +++ b/frontend/cypress/e2e/campaigns.cy.js @@ -12,8 +12,41 @@ describe('Campaigns', () => { cy.get('tbody td[data-label=Status]').should('have.length', 1); }); + it('Creates campaign', () => { + cy.get('a[data-cy=btn-new]').click(); + + // Fill fields. + cy.get('input[name=name]').clear().type('new-attach'); + cy.get('input[name=subject]').clear().type('new-subject'); + cy.get('input[name=from_email]').clear().type('new '); + cy.get('.list-selector input').click(); + cy.get('.list-selector .autocomplete a').eq(0).click(); + + cy.get('button[data-cy=btn-continue]').click(); + cy.wait(500); + + cy.get('a[data-cy=btn-attach]').click(); + cy.get('input[type=file]').attachFile('example.json'); + cy.get('.modal button.is-primary').click(); + cy.get('.modal .thumb a.link').click(); + cy.get('button[data-cy=btn-save]').click(); + cy.wait(500); + + // Re-open and check that the file still exists. + cy.loginAndVisit('/campaigns'); + cy.get('td[data-label=Status] a').eq(0).click(); + cy.get('.b-tabs nav a').eq(1).click(); + cy.get('div.field[data-cy=media]').contains('example'); + + // Start. + cy.get('button[data-cy=btn-start]').click(); + cy.get('.modal button.is-primary').click(); + cy.wait(500); + cy.get('tbody tr').eq(0).get('td[data-label=Status] .tag.running'); + }); + it('Edits campaign', () => { - cy.get('td[data-label=Status] a').click(); + cy.get('td[data-label=Status] a').eq(1).click(); // Fill fields. cy.get('input[name=name]').clear().type('new-name'); @@ -196,7 +229,7 @@ describe('Campaigns', () => { cy.wait(250); // Verify the changes. - (function(n) { + (function (n) { cy.location('pathname').then((p) => { cy.request(`${apiUrl}/api/campaigns/${p.split('/').at(-1)}`).should((response) => { const { data } = response.body; diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 280c612..a22fc0b 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -725,6 +725,8 @@ section.campaigns { } } +.campaign + section.analytics { .charts { position: relative; @@ -775,18 +777,41 @@ section.analytics { max-height: 90px; overflow: hidden; position: relative; + width: 250px; + min-height: 250px; + text-align: center; + + .link { + display: block; + } + + .ext { + display: block; + margin-top: 60px; + font-size: 2rem; + text-transform: uppercase; + } + + .filename { + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: $grey; + } img { - max-width: 120px; + max-width: 250px; } .caption { background-color: rgba($white, .70); + color: $grey; position: absolute; bottom: 0; left: 0; right: 0; - padding: 2px 5px; + padding: 5px 15px; white-space: nowrap; overflow: hidden; @@ -818,6 +843,19 @@ section.analytics { } } +.modal .media-files { + .thumb { + min-width: 175px; + width: 175px; + min-height: 175px; + } + + .gallery { + padding-left: 0; + padding-right: 0; + } +} + /* Template form */ .templates { td .tag { diff --git a/frontend/src/components/Editor.vue b/frontend/src/components/Editor.vue index 901bcfa..b67aa63 100644 --- a/frontend/src/components/Editor.vue +++ b/frontend/src/components/Editor.vue @@ -102,7 +102,7 @@ diff --git a/frontend/src/views/Campaign.vue b/frontend/src/views/Campaign.vue index b32b267..0db7c55 100644 --- a/frontend/src/views/Campaign.vue +++ b/frontend/src/views/Campaign.vue @@ -130,7 +130,7 @@

- + {{ $t('settings.smtp.setCustomHeaders') }}

@@ -183,17 +183,36 @@ :disabled="!canEdit" /> + + @@ -251,6 +270,14 @@ + + + + @@ -262,6 +289,7 @@ import htmlToPlainText from 'textversionjs'; import ListSelector from '../components/ListSelector.vue'; import Editor from '../components/Editor.vue'; +import Media from './Media.vue'; const TABS = ['campaign', 'content', 'archive']; @@ -269,6 +297,7 @@ export default Vue.extend({ components: { ListSelector, Editor, + Media, }, data() { @@ -276,6 +305,8 @@ export default Vue.extend({ isNew: false, isEditing: false, isHeadersVisible: false, + isAttachFieldVisible: false, + isAttachModalOpen: false, activeTab: 0, data: {}, @@ -297,6 +328,7 @@ export default Vue.extend({ sendAt: null, content: { contentType: 'richtext', body: '' }, altbody: null, + media: [], // Parsed Date() version of send_at from the API. sendAtDate: null, @@ -314,18 +346,37 @@ export default Vue.extend({ return dayjs(s).format('YYYY-MM-DD HH:mm'); }, - addAltBody() { + onAddAltBody() { this.form.altbody = htmlToPlainText(this.form.content.body); }, - removeAltBody() { + onRemoveAltBody() { this.form.altbody = null; }, - showHeaders() { + onShowHeaders() { this.isHeadersVisible = !this.isHeadersVisible; }, + onShowAttachField() { + this.isAttachFieldVisible = true; + this.$nextTick(() => { + this.$refs.media.focus(); + }); + }, + + onOpenAttach() { + this.isAttachModalOpen = true; + }, + + onAttachSelect(o) { + if (this.form.media.some((m) => m.id === o.id)) { + return; + } + + this.form.media.push(o); + }, + isUnsaved() { return this.data.body !== this.form.content.body || this.data.contentType !== this.form.content.contentType; @@ -395,6 +446,14 @@ export default Vue.extend({ // The structure that is populated by editor input event. content: { contentType: data.contentType, body: data.body }, }; + this.isAttachFieldVisible = this.form.media.length > 0; + + this.form.media = this.form.media.map((f) => { + if (!f.id) { + return { ...f, filename: `❌ ${f.filename}` }; + } + return f; + }); if (data.sendAt !== null) { this.form.sendLater = true; @@ -419,6 +478,7 @@ export default Vue.extend({ body: this.form.content.body, altbody: this.form.content.contentType !== 'plain' ? this.form.altbody : null, subscribers: this.form.testEmails, + media: this.form.media.map((m) => m.id), }; this.$api.testCampaign(data).then(() => { @@ -441,6 +501,7 @@ export default Vue.extend({ send_at: this.form.sendLater ? this.form.sendAtDate : null, headers: this.form.headers, template_id: this.form.templateId, + media: this.form.media.map((m) => m.id), // body: this.form.body, }; @@ -469,6 +530,7 @@ export default Vue.extend({ archive: this.form.archive, archive_template_id: this.form.archiveTemplateId, archive_meta: this.form.archiveMeta, + media: this.form.media.map((m) => m.id), }; let typMsg = 'globals.messages.updated'; diff --git a/frontend/src/views/Campaigns.vue b/frontend/src/views/Campaigns.vue index d0d9bd5..72f5a38 100644 --- a/frontend/src/views/Campaigns.vue +++ b/frontend/src/views/Campaigns.vue @@ -426,6 +426,7 @@ export default Vue.extend({ archive: c.archive, archive_template_id: c.archiveTemplateId, archive_meta: c.archiveMeta, + media: c.media.map((m) => m.id), }; this.$api.createCampaign(data).then((d) => { diff --git a/frontend/src/views/Media.vue b/frontend/src/views/Media.vue index 8860778..3558944 100644 --- a/frontend/src/views/Media.vue +++ b/frontend/src/views/Media.vue @@ -16,7 +16,7 @@ v-model="form.files" drag-drop multiple - accept=".png,.jpg,.jpeg,.gif,.svg" + xaccept=".png,.jpg,.jpeg,.gif,.svg" expanded>