From 869a55c1efcbef5ce7f63cb60273d5c8275d2588 Mon Sep 17 00:00:00 2001 From: Karan Sharma Date: Thu, 21 Jan 2021 21:36:32 +0530 Subject: [PATCH 01/43] feat: Add shell script for demo setup --- README.md | 6 +++++- install-demo.sh | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100755 install-demo.sh diff --git a/README.md b/README.md index 008f42a..32943c1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,11 @@ Visit [listmonk.app](https://listmonk.app) The latest image is available on DockerHub at `listmonk/listmonk:latest`. Use the sample [docker-compose.yml](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to run listmonk and Postgres DB with docker-compose as follows: #### Demo -`docker-compose up -d demo-db demo-app` + +```bash +mkdir listmonk-demo +sh -c "$(curl -sSL https://raw.githubusercontent.com/knadh/listmonk/master/install-demo.sh)" +``` The demo does not persist Postgres after the containers are removed. DO NOT use this demo setup in production. diff --git a/install-demo.sh b/install-demo.sh new file mode 100755 index 0000000..785d9f4 --- /dev/null +++ b/install-demo.sh @@ -0,0 +1,40 @@ +#!/bin/sh + +set -e + +# Listmonk demo setup using `docker-compose`. +# +# See https://listmonk.app/docs/installation/ for detailed installation steps. +# + + +check_dependency() { + if ! command -v curl > /dev/null; then + echo "curl is not installed." + exit 1 + fi + + if ! command -v docker > /dev/null; then + echo "docker is not installed." + exit 1 + fi + + if ! command -v docker-compose > /dev/null; then + echo "docker-compose is not installed." + exit 1 + fi +} + +setup_containers() { + curl -o docker-compose.yml https://raw.githubusercontent.com/knadh/listmonk/master/docker-compose.yml + docker-compose up -d demo-db demo-app +} + +show_output(){ + echo -e "\nListmonk is now up and running. Visit http://localhost:9000 in your browser.\n" +} + + +check_dependency +setup_containers +show_output From 8867f771d45ef0a786f1521c122a6d569c66a87a Mon Sep 17 00:00:00 2001 From: Joe Paul Date: Wed, 27 Jan 2021 12:55:53 +0530 Subject: [PATCH 02/43] fix: Stop tickers --- cmd/updates.go | 2 ++ internal/manager/manager.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/cmd/updates.go b/cmd/updates.go index 09141b4..73f327d 100644 --- a/cmd/updates.go +++ b/cmd/updates.go @@ -34,6 +34,8 @@ func checkUpdates(curVersion string, interval time.Duration, app *App) { curVersion = reSemver.ReplaceAllString(curVersion, "") time.Sleep(time.Second * 1) ticker := time.NewTicker(interval) + defer ticker.Stop() + for ; true; <-ticker.C { resp, err := http.Get(updateCheckURL) if err != nil { diff --git a/internal/manager/manager.go b/internal/manager/manager.go index bf9b522..d4075ab 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -348,6 +348,8 @@ func (m *Manager) Close() { // for campaigns to process and dispatches them to the manager. func (m *Manager) scanCampaigns(tick time.Duration) { t := time.NewTicker(tick) + defer t.Stop() + for { select { // Periodically scan the data source for campaigns to process. From ee46b3d3d8a3d6154be03076c56786ca97f4851d Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sat, 6 Feb 2021 18:58:11 +0530 Subject: [PATCH 03/43] Fix blank UI on forms pages when there are no public lists --- frontend/src/views/Forms.vue | 6 ++++++ i18n/en.json | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/Forms.vue b/frontend/src/views/Forms.vue index 9fb494f..d32ca32 100644 --- a/frontend/src/views/Forms.vue +++ b/frontend/src/views/Forms.vue @@ -2,7 +2,13 @@

{{ $t('forms.title') }}


+ +

+ {{ $t('forms.noPublicLists') }} +

+ +

{{ $t('forms.publicLists') }}

diff --git a/i18n/en.json b/i18n/en.json index e514c2c..727248e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -92,6 +92,7 @@ "email.unsubHelp": "Don't want to receive these e-mails?", "forms.formHTML": "Form HTML", "forms.formHTMLHelp": "Use the following HTML to show a subscription form on an external webpage. The form should have the email field and one or more `l` (list UUID) fields. The name field is optional.", + "forms.noPublicLists": "There are no public lists to generate a forms.", "forms.publicLists": "Public lists", "forms.publicSubPage": "Public subscription page", "forms.selectHelp": "Select lists to add to the form.", @@ -257,12 +258,12 @@ "public.privacyWipe": "Wipe your data", "public.privacyWipeHelp": "Delete all your subscriptions and related data from the database permanently.", "public.sub": "Subscribe", - "public.subTitle": "Subscribe", - "public.subName": "Name (optional)", "public.subConfirmed": "Subscribed successfully.", "public.subConfirmedTitle": "Confirmed", + "public.subName": "Name (optional)", "public.subNotFound": "Subscription not found.", "public.subPrivateList": "Private list", + "public.subTitle": "Subscribe", "public.unsub": "Unsubscribe", "public.unsubFull": "Also unsubscribe from all future e-mails.", "public.unsubHelp": "Do you want to unsubscribe from this mailing list?", @@ -273,10 +274,10 @@ "settings.duplicateMessengerName": "Duplicate messenger name: {name}", "settings.errorEncoding": "Error encoding settings: {error}", "settings.errorNoSMTP": "At least one SMTP block should be enabled", - "settings.general.enablePublicSubPage": "Enable public subscription page", - "settings.general.enablePublicSubPageHelp": "Show a public subscription page with all the public lists for people to subscribe.", "settings.general.adminNotifEmails": "Admin notification e-mails", "settings.general.adminNotifEmailsHelp": "Comma separated list of e-mail addresses to which admin notifications such as import updates, campaign completion, failure etc. should be sent.", + "settings.general.enablePublicSubPage": "Enable public subscription page", + "settings.general.enablePublicSubPageHelp": "Show a public subscription page with all the public lists for people to subscribe.", "settings.general.faviconURL": "Favicon URL", "settings.general.faviconURLHelp": "(Optional) full URL to the static favicon to be displayed on user facing view such as the unsubscription page.", "settings.general.fromEmail": "Default `from` email", From b950d2f4ffb09ab08ade2f26ef694641ff5cd01a Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sat, 13 Feb 2021 12:34:36 +0530 Subject: [PATCH 04/43] Refactor fetching of server config and settings. The earlier approach of loading `/api/config.js` as a script on initial page load with the necessary variables to init the UI is ditched. Instead, it's now `/api/config` and `/api/settings` like all other API calls. On load of the frontend, these two resources are fetched and the frontend is initialised. --- cmd/admin.go | 48 +++++++------------------- cmd/handlers.go | 23 ++----------- cmd/i18n.go | 53 ++++++++++++++++++++++++++-- cmd/init.go | 22 ++---------- frontend/README.md | 7 ++-- frontend/public/index.html | 1 - frontend/src/App.vue | 28 +++++---------- frontend/src/api/index.js | 8 ++++- frontend/src/constants.js | 4 +-- frontend/src/main.js | 61 +++++++++++++-------------------- frontend/src/views/Campaign.vue | 12 +++++-- frontend/src/views/Forms.vue | 11 +++--- frontend/src/views/Lists.vue | 4 +-- frontend/src/views/Media.vue | 4 +-- frontend/src/views/Settings.vue | 18 +++++----- i18n/en.json | 4 +++ 16 files changed, 142 insertions(+), 166 deletions(-) diff --git a/cmd/admin.go b/cmd/admin.go index 289ef20..292226b 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -1,8 +1,6 @@ package main import ( - "bytes" - "encoding/json" "fmt" "net/http" "sort" @@ -13,41 +11,29 @@ import ( "github.com/labstack/echo" ) -type configScript struct { - RootURL string `json:"rootURL"` - FromEmail string `json:"fromEmail"` - Messengers []string `json:"messengers"` - MediaProvider string `json:"mediaProvider"` - NeedsRestart bool `json:"needsRestart"` - Update *AppUpdate `json:"update"` - Langs []i18nLang `json:"langs"` - EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"` - Lang json.RawMessage `json:"lang"` +type serverConfig struct { + Messengers []string `json:"messengers"` + Langs []i18nLang `json:"langs"` + Lang string `json:"lang"` + Update *AppUpdate `json:"update"` + NeedsRestart bool `json:"needs_restart"` } -// handleGetConfigScript returns general configuration as a Javascript -// variable that can be included in an HTML page directly. -func handleGetConfigScript(c echo.Context) error { +// handleGetServerConfig returns general server config. +func handleGetServerConfig(c echo.Context) error { var ( app = c.Get("app").(*App) - out = configScript{ - RootURL: app.constants.RootURL, - FromEmail: app.constants.FromEmail, - MediaProvider: app.constants.MediaProvider, - EnablePublicSubPage: app.constants.EnablePublicSubPage, - } + out = serverConfig{} ) // Language list. - langList, err := geti18nLangList(app.constants.Lang, app) + langList, err := getI18nLangList(app.constants.Lang, app) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Error loading language list: %v", err)) } out.Langs = langList - - // Current language. - out.Lang = json.RawMessage(app.i18n.JSON()) + out.Lang = app.constants.Lang // Sort messenger names with `email` always as the first item. var names []string @@ -66,17 +52,7 @@ func handleGetConfigScript(c echo.Context) error { out.Update = app.update app.Unlock() - // Write the Javascript variable opening; - b := bytes.Buffer{} - b.Write([]byte(`var CONFIG = `)) - - // Encode the config payload as JSON and write as the variable's value assignment. - j := json.NewEncoder(&b) - if err := j.Encode(out); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - app.i18n.Ts("admin.errorMarshallingConfig", "error", err.Error())) - } - return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes()) + return c.JSON(http.StatusOK, okResp{out}) } // handleGetDashboardCharts returns chart data points to render ont he dashboard. diff --git a/cmd/handlers.go b/cmd/handlers.go index a399460..b1eaef0 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -2,8 +2,6 @@ package main import ( "crypto/subtle" - "encoding/json" - "fmt" "net/http" "net/url" "regexp" @@ -44,8 +42,8 @@ func registerHTTPHandlers(e *echo.Echo) { g := e.Group("", middleware.BasicAuth(basicAuth)) g.GET("/", handleIndexPage) g.GET("/api/health", handleHealthCheck) - g.GET("/api/config.js", handleGetConfigScript) - g.GET("/api/lang/:lang", handleLoadLanguage) + g.GET("/api/config", handleGetServerConfig) + g.GET("/api/lang/:lang", handleGetI18nLang) g.GET("/api/dashboard/charts", handleGetDashboardCharts) g.GET("/api/dashboard/counts", handleGetDashboardCounts) @@ -164,23 +162,6 @@ func handleHealthCheck(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } -// handleLoadLanguage returns the JSON language pack given the language code. -func handleLoadLanguage(c echo.Context) error { - app := c.Get("app").(*App) - - lang := c.Param("lang") - if len(lang) > 6 || reLangCode.MatchString(lang) { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") - } - - b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang)) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") - } - - return c.JSON(http.StatusOK, okResp{json.RawMessage(b)}) -} - // basicAuth middleware does an HTTP BasicAuth authentication for admin handlers. func basicAuth(username, password string, c echo.Context) (bool, error) { app := c.Get("app").(*App) diff --git a/cmd/i18n.go b/cmd/i18n.go index f91b2f8..66bb0e9 100644 --- a/cmd/i18n.go +++ b/cmd/i18n.go @@ -3,6 +3,11 @@ package main import ( "encoding/json" "fmt" + "net/http" + + "github.com/knadh/listmonk/internal/i18n" + "github.com/knadh/stuffbin" + "github.com/labstack/echo" ) type i18nLang struct { @@ -15,8 +20,25 @@ type i18nLangRaw struct { Name string `json:"_.name"` } -// geti18nLangList returns the list of available i18n languages. -func geti18nLangList(lang string, app *App) ([]i18nLang, error) { +// handleGetI18nLang returns the JSON language pack given the language code. +func handleGetI18nLang(c echo.Context) error { + app := c.Get("app").(*App) + + lang := c.Param("lang") + if len(lang) > 6 || reLangCode.MatchString(lang) { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.") + } + + i, err := getI18nLang(lang, app.fs) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.") + } + + return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())}) +} + +// getI18nLangList returns the list of available i18n languages. +func getI18nLangList(lang string, app *App) ([]i18nLang, error) { list, err := app.fs.Glob("/i18n/*.json") if err != nil { return nil, err @@ -42,3 +64,30 @@ func geti18nLangList(lang string, app *App) ([]i18nLang, error) { return out, nil } + +func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) { + const def = "en" + + b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) + if err != nil { + return nil, fmt.Errorf("error reading default i18n language file: %s: %v", def, err) + } + + // Initialize with the default language. + i, err := i18n.New(b) + if err != nil { + return nil, fmt.Errorf("error unmarshalling i18n language: %v", err) + } + + // Load the selected language on top of it. + b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) + if err != nil { + return nil, fmt.Errorf("error reading i18n language file: %v", err) + } + + if err := i.Load(b); err != nil { + return nil, fmt.Errorf("error loading i18n language file: %v", err) + } + + return i, nil +} diff --git a/cmd/init.go b/cmd/init.go index fab4fee..9170f9b 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -262,28 +262,10 @@ func initConstants() *constants { // and then the selected language is loaded on top of it so that if there are // missing translations in it, the default English translations show up. func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n { - const def = "en" - - b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def)) + i, err := getI18nLang(lang, fs) if err != nil { - lo.Fatalf("error reading default i18n language file: %s: %v", def, err) + lo.Fatal(err) } - - // Initialize with the default language. - i, err := i18n.New(b) - if err != nil { - lo.Fatalf("error unmarshalling i18n language: %v", err) - } - - // Load the selected language on top of it. - b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang)) - if err != nil { - lo.Fatalf("error reading i18n language file: %v", err) - } - if err := i.Load(b); err != nil { - lo.Fatalf("error loading i18n language file: %v", err) - } - return i } diff --git a/frontend/README.md b/frontend/README.md index 51964ae..85ae477 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,7 +5,10 @@ It's best if the `listmonk/frontend` directory is opened in an IDE as a separate For developer setup instructions, refer to the main project's README. ## Globals -`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`), `$utils` (util functions from `util.js`), `$serverConfig` (loaded form /api/config.js) are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`. +In `main.js`, Buefy and vue-i18n are attached globally. In addition: + +- `$api` (collection of API calls from `api/index.js`) +- `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`. Some constants are defined in `constants.js`. @@ -14,7 +17,7 @@ The project uses a global `vuex` state to centrally store the responses to prett There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses. -*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. +*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`. ## Icon pack diff --git a/frontend/public/index.html b/frontend/public/index.html index 8aa84f0..f0801d8 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -7,7 +7,6 @@ <%= htmlWebpackPlugin.options.title %> -