diff --git a/campaigns.go b/campaigns.go index 64c9cf4..e6b7fc5 100644 --- a/campaigns.go +++ b/campaigns.go @@ -560,16 +560,12 @@ func sendTestMessage(sub models.Subscriber, camp *models.Campaign, app *App) err fmt.Sprintf("Error rendering message: %v", err)) } - if err := app.messenger.Push(messenger.Message{ + return app.messenger.Push(messenger.Message{ From: camp.FromEmail, To: []string{sub.Email}, Subject: m.Subject(), Body: m.Body(), - }); err != nil { - return err - } - - return nil + }) } // validateCampaignFields validates incoming campaign field values. diff --git a/config.toml.sample b/config.toml.sample index 514ca8d..7c0e3c6 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -2,6 +2,13 @@ # Interface and port where the app will run its webserver. address = "0.0.0.0:9000" + # BasicAuth authentication for the admin dashboard. This will eventually + # be replaced with a better multi-user, role-based authentication system. + # IMPORTANT: Leave both values empty to disable authentication on admin + # only where an external authentication is already setup. + admin_username = "listmonk" + admin_password = "listmonk" + # Database. [db] host = "db" diff --git a/go.mod b/go.mod index a8c8ed8..f9d34eb 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/knadh/listmonk go 1.13 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/disintegration/imaging v1.6.2 github.com/gofrs/uuid v3.2.0+incompatible github.com/jaytaylor/html2text v0.0.0-20200220170450-61d9dc4d7195 diff --git a/go.sum b/go.sum index 0025896..44a74a3 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,9 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v1.0.2 h1:KPldsxuKGsS2FPWsNeg9ZO18aCrGKujPoWXn2yo+KQM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= diff --git a/handlers.go b/handlers.go index 67908ba..b3c481c 100644 --- a/handlers.go +++ b/handlers.go @@ -1,12 +1,14 @@ package main import ( + "crypto/subtle" "net/http" "net/url" "regexp" "strconv" "github.com/labstack/echo" + "github.com/labstack/echo/middleware" ) const ( @@ -30,71 +32,87 @@ var reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[ // registerHandlers registers HTTP handlers. func registerHTTPHandlers(e *echo.Echo) { - e.GET("/", handleIndexPage) - e.GET("/api/health", handleHealthCheck) - e.GET("/api/config.js", handleGetConfigScript) - e.GET("/api/dashboard/charts", handleGetDashboardCharts) - e.GET("/api/dashboard/counts", handleGetDashboardCounts) + // Group of private handlers with BasicAuth. + g := e.Group("", middleware.BasicAuth(basicAuth)) - e.GET("/api/settings", handleGetSettings) - e.PUT("/api/settings", handleUpdateSettings) - e.POST("/api/admin/reload", handleReloadApp) + g.GET("/", handleIndexPage) + g.GET("/api/health", handleHealthCheck) + g.GET("/api/config.js", handleGetConfigScript) + g.GET("/api/dashboard/charts", handleGetDashboardCharts) + g.GET("/api/dashboard/counts", handleGetDashboardCounts) - e.GET("/api/subscribers/:id", handleGetSubscriber) - e.GET("/api/subscribers/:id/export", handleExportSubscriberData) - e.POST("/api/subscribers", handleCreateSubscriber) - e.PUT("/api/subscribers/:id", handleUpdateSubscriber) - e.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin) - e.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers) - e.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers) - e.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists) - e.PUT("/api/subscribers/lists", handleManageSubscriberLists) - e.DELETE("/api/subscribers/:id", handleDeleteSubscribers) - e.DELETE("/api/subscribers", handleDeleteSubscribers) + g.GET("/api/settings", handleGetSettings) + g.PUT("/api/settings", handleUpdateSettings) + g.POST("/api/admin/reload", handleReloadApp) + + g.GET("/api/subscribers/:id", handleGetSubscriber) + g.GET("/api/subscribers/:id/export", handleExportSubscriberData) + g.POST("/api/subscribers", handleCreateSubscriber) + g.PUT("/api/subscribers/:id", handleUpdateSubscriber) + g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin) + g.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers) + g.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers) + g.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists) + g.PUT("/api/subscribers/lists", handleManageSubscriberLists) + g.DELETE("/api/subscribers/:id", handleDeleteSubscribers) + g.DELETE("/api/subscribers", handleDeleteSubscribers) // Subscriber operations based on arbitrary SQL queries. // These aren't very REST-like. - e.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery) - e.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery) - e.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) - e.GET("/api/subscribers", handleQuerySubscribers) + g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery) + g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery) + g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery) + g.GET("/api/subscribers", handleQuerySubscribers) - e.GET("/api/import/subscribers", handleGetImportSubscribers) - e.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats) - e.POST("/api/import/subscribers", handleImportSubscribers) - e.DELETE("/api/import/subscribers", handleStopImportSubscribers) + g.GET("/api/import/subscribers", handleGetImportSubscribers) + g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats) + g.POST("/api/import/subscribers", handleImportSubscribers) + g.DELETE("/api/import/subscribers", handleStopImportSubscribers) - e.GET("/api/lists", handleGetLists) - e.GET("/api/lists/:id", handleGetLists) - e.POST("/api/lists", handleCreateList) - e.PUT("/api/lists/:id", handleUpdateList) - e.DELETE("/api/lists/:id", handleDeleteLists) + g.GET("/api/lists", handleGetLists) + g.GET("/api/lists/:id", handleGetLists) + g.POST("/api/lists", handleCreateList) + g.PUT("/api/lists/:id", handleUpdateList) + g.DELETE("/api/lists/:id", handleDeleteLists) - e.GET("/api/campaigns", handleGetCampaigns) - e.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) - e.GET("/api/campaigns/:id", handleGetCampaigns) - e.GET("/api/campaigns/:id/preview", handlePreviewCampaign) - e.POST("/api/campaigns/:id/preview", handlePreviewCampaign) - e.POST("/api/campaigns/:id/test", handleTestCampaign) - e.POST("/api/campaigns", handleCreateCampaign) - e.PUT("/api/campaigns/:id", handleUpdateCampaign) - e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) - e.DELETE("/api/campaigns/:id", handleDeleteCampaign) + g.GET("/api/campaigns", handleGetCampaigns) + g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) + g.GET("/api/campaigns/:id", handleGetCampaigns) + g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) + g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) + g.POST("/api/campaigns/:id/test", handleTestCampaign) + g.POST("/api/campaigns", handleCreateCampaign) + g.PUT("/api/campaigns/:id", handleUpdateCampaign) + g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) + g.DELETE("/api/campaigns/:id", handleDeleteCampaign) - e.GET("/api/media", handleGetMedia) - e.POST("/api/media", handleUploadMedia) - e.DELETE("/api/media/:id", handleDeleteMedia) + g.GET("/api/media", handleGetMedia) + g.POST("/api/media", handleUploadMedia) + g.DELETE("/api/media/:id", handleDeleteMedia) - e.GET("/api/templates", handleGetTemplates) - e.GET("/api/templates/:id", handleGetTemplates) - e.GET("/api/templates/:id/preview", handlePreviewTemplate) - e.POST("/api/templates/preview", handlePreviewTemplate) - e.POST("/api/templates", handleCreateTemplate) - e.PUT("/api/templates/:id", handleUpdateTemplate) - e.PUT("/api/templates/:id/default", handleTemplateSetDefault) - e.DELETE("/api/templates/:id", handleDeleteTemplate) + g.GET("/api/templates", handleGetTemplates) + g.GET("/api/templates/:id", handleGetTemplates) + g.GET("/api/templates/:id/preview", handlePreviewTemplate) + g.POST("/api/templates/preview", handlePreviewTemplate) + g.POST("/api/templates", handleCreateTemplate) + g.PUT("/api/templates/:id", handleUpdateTemplate) + g.PUT("/api/templates/:id/default", handleTemplateSetDefault) + g.DELETE("/api/templates/:id", handleDeleteTemplate) - // Subscriber facing views. + // Static admin views. + g.GET("/lists", handleIndexPage) + g.GET("/lists/forms", handleIndexPage) + g.GET("/subscribers", handleIndexPage) + g.GET("/subscribers/lists/:listID", handleIndexPage) + g.GET("/subscribers/import", handleIndexPage) + g.GET("/campaigns", handleIndexPage) + g.GET("/campaigns/new", handleIndexPage) + g.GET("/campaigns/media", handleIndexPage) + g.GET("/campaigns/templates", handleIndexPage) + g.GET("/campaigns/:campignID", handleIndexPage) + g.GET("/settings", handleIndexPage) + + // Public subscriber facing views. e.POST("/subscription/form", handleSubscriptionForm) e.GET("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID")) @@ -112,19 +130,6 @@ func registerHTTPHandlers(e *echo.Echo) { "campUUID", "subUUID")) e.GET("/campaign/:campUUID/:subUUID/px.png", validateUUID(handleRegisterCampaignView, "campUUID", "subUUID")) - - // Static views. - e.GET("/lists", handleIndexPage) - e.GET("/lists/forms", handleIndexPage) - e.GET("/subscribers", handleIndexPage) - e.GET("/subscribers/lists/:listID", handleIndexPage) - e.GET("/subscribers/import", handleIndexPage) - e.GET("/campaigns", handleIndexPage) - e.GET("/campaigns/new", handleIndexPage) - e.GET("/campaigns/media", handleIndexPage) - e.GET("/campaigns/templates", handleIndexPage) - e.GET("/campaigns/:campignID", handleIndexPage) - e.GET("/settings", handleIndexPage) } // handleIndex is the root handler that renders the Javascript frontend. @@ -145,6 +150,23 @@ func handleHealthCheck(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } +// 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) + + // Auth is disabled. + if len(app.constants.AdminUsername) == 0 && + len(app.constants.AdminPassword) == 0 { + return true, nil + } + + if subtle.ConstantTimeCompare([]byte(username), app.constants.AdminUsername) == 1 && + subtle.ConstantTimeCompare([]byte(password), app.constants.AdminPassword) == 1 { + return true, nil + } + return false, nil +} + // validateUUID middleware validates the UUID string format for a given set of params. func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/init.go b/init.go index 19acded..651a3a2 100644 --- a/init.go +++ b/init.go @@ -48,13 +48,14 @@ type constants struct { AllowWipe bool `koanf:"allow_wipe"` Exportable map[string]bool `koanf:"-"` } `koanf:"privacy"` + AdminUsername []byte `koanf:"admin_username"` + AdminPassword []byte `koanf:"admin_password"` - UnsubURL string - LinkTrackURL string - ViewTrackURL string - OptinURL string - MessageURL string - + UnsubURL string + LinkTrackURL string + ViewTrackURL string + OptinURL string + MessageURL string MediaProvider string } diff --git a/install.go b/install.go index 45ddac3..de7e742 100644 --- a/install.go +++ b/install.go @@ -5,6 +5,7 @@ import ( "fmt" "io/ioutil" "os" + "regexp" "strings" "github.com/gofrs/uuid" @@ -170,5 +171,12 @@ func newConfigFile() error { return fmt.Errorf("error reading sample config (is binary stuffed?): %v", err) } + // Generate a random admin password. + pwd, err := generateRandomString(16) + if err == nil { + b = regexp.MustCompile(`admin_password\s+?=\s+?(.*)`). + ReplaceAll(b, []byte(fmt.Sprintf(`admin_password = "%s"`, pwd))) + } + return ioutil.WriteFile("config.toml", b, 0644) } diff --git a/internal/migrations/v0.7.0.go b/internal/migrations/v0.7.0.go index 871ac15..a0984bd 100644 --- a/internal/migrations/v0.7.0.go +++ b/internal/migrations/v0.7.0.go @@ -75,6 +75,7 @@ func V0_7_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf) error { ('app.batch_size', '1000'), ('app.max_send_errors', '1000'), ('app.notify_emails', '["admin1@mysite.com", "admin2@mysite.com"]'), + ('privacy.unsubscribe_header', 'true'), ('privacy.allow_blocklist', 'true'), ('privacy.allow_export', 'true'), ('privacy.allow_wipe', 'true'),