diff --git a/Makefile b/Makefile index e876313dd..7eebb7154 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ FRONTEND_DEPS = \ BIN := listmonk STATIC := config.toml.sample \ - schema.sql queries.sql \ + schema.sql queries.sql permissions.json \ static/public:/public \ static/email-templates \ frontend/dist:/admin \ @@ -38,7 +38,7 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock touch -c $(FRONTEND_YARN_MODULES) # Build the backend to ./listmonk. -$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum +$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go # Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist. diff --git a/cmd/admin.go b/cmd/admin.go index 444eb6506..0f6b39e5e 100644 --- a/cmd/admin.go +++ b/cmd/admin.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "net/http" "sort" @@ -11,20 +12,30 @@ import ( ) type serverConfig struct { - Messengers []string `json:"messengers"` - Langs []i18nLang `json:"langs"` - Lang string `json:"lang"` - Update *AppUpdate `json:"update"` - NeedsRestart bool `json:"needs_restart"` - Version string `json:"version"` + RootURL string `json:"root_url"` + FromEmail string `json:"from_email"` + Messengers []string `json:"messengers"` + Langs []i18nLang `json:"langs"` + Lang string `json:"lang"` + Permissions json.RawMessage `json:"permissions"` + Update *AppUpdate `json:"update"` + NeedsRestart bool `json:"needs_restart"` + HasLegacyUser bool `json:"has_legacy_user"` + Version string `json:"version"` } // handleGetServerConfig returns general server config. func handleGetServerConfig(c echo.Context) error { var ( app = c.Get("app").(*App) - out = serverConfig{} ) + out := serverConfig{ + RootURL: app.constants.RootURL, + FromEmail: app.constants.FromEmail, + Lang: app.constants.Lang, + Permissions: app.constants.PermissionsRaw, + HasLegacyUser: app.constants.HasLegacyUser, + } // Language list. langList, err := getI18nLangList(app.constants.Lang, app) @@ -33,7 +44,6 @@ func handleGetServerConfig(c echo.Context) error { fmt.Sprintf("Error loading language list: %v", err)) } out.Langs = langList - out.Lang = app.constants.Lang // Sort messenger names with `email` always as the first item. var names []string diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 000000000..73a8594ce --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,221 @@ +package main + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/utils" + "github.com/labstack/echo/v4" + "github.com/zerodha/simplesessions/v3" +) + +type loginTpl struct { + Title string + Description string + + NextURI string + Nonce string + PasswordEnabled bool + OIDCProvider string + OIDCProviderLogo string + Error string +} + +var oidcProviders = map[string]bool{ + "google.com": true, + "microsoftonline.com": true, + "auth0.com": true, + "github.com": true, +} + +// handleLoginPage renders the login page and handles the login form. +func handleLoginPage(c echo.Context) error { + // Process POST login request. + var loginErr error + if c.Request().Method == http.MethodPost { + loginErr = doLogin(c) + if loginErr == nil { + return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next"))) + } + } + + return renderLoginPage(c, loginErr) +} + +// handleLogout logs a user out. +func handleLogout(c echo.Context) error { + var ( + sess = c.Get(auth.SessionKey).(*simplesessions.Session) + ) + + // Clear the session. + _ = sess.Destroy() + + return c.JSON(http.StatusOK, okResp{true}) +} + +// handleOIDCLogin initializes an OIDC request and redirects to the OIDC provider for login. +func handleOIDCLogin(c echo.Context) error { + app := c.Get("app").(*App) + + // Verify that the request came from the login page (CSRF). + nonce, err := c.Cookie("nonce") + if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") { + return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")) + } + + next := utils.SanitizeURI(c.FormValue("next")) + if next == "/" { + next = uriAdmin + } + + return c.Redirect(http.StatusFound, app.auth.GetOIDCAuthURL(next, nonce.Value)) +} + +// handleOIDCFinish receives the redirect callback from the OIDC provider and completes the handshake. +func handleOIDCFinish(c echo.Context) error { + app := c.Get("app").(*App) + + nonce, err := c.Cookie("nonce") + if err != nil || nonce.Value == "" { + return renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))) + } + + // Validate the OIDC token. + oidcToken, claims, err := app.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value) + if err != nil { + return renderLoginPage(c, err) + } + + // Get the user by e-mail received from OIDC. + user, err := app.core.GetUser(0, "", claims.Email) + if err != nil { + return renderLoginPage(c, err) + } + + // Update user login. + if err := app.core.UpdateUserLogin(user.ID, claims.Picture); err != nil { + return renderLoginPage(c, err) + } + + // Set the session. + if err := app.auth.SaveSession(user, oidcToken, c); err != nil { + return renderLoginPage(c, err) + } + + return c.Redirect(http.StatusFound, utils.SanitizeURI(c.QueryParam("state"))) +} + +// renderLoginPage renders the login page and handles the login form. +func renderLoginPage(c echo.Context, loginErr error) error { + var ( + app = c.Get("app").(*App) + next = utils.SanitizeURI(c.FormValue("next")) + ) + + if next == "/" { + next = uriAdmin + } + + oidcProvider := "" + oidcProviderLogo := "" + if app.constants.Security.OIDC.Enabled { + oidcProviderLogo = "oidc.png" + u, err := url.Parse(app.constants.Security.OIDC.Provider) + if err == nil { + h := strings.Split(u.Hostname(), ".") + + // Get the last two h for the root domain + if len(h) >= 2 { + oidcProvider = h[len(h)-2] + "." + h[len(h)-1] + } else { + oidcProvider = u.Hostname() + } + + if _, ok := oidcProviders[oidcProvider]; ok { + oidcProviderLogo = oidcProvider + ".png" + } + } + } + + out := loginTpl{ + Title: app.i18n.T("users.login"), + PasswordEnabled: true, + OIDCProvider: oidcProvider, + OIDCProviderLogo: oidcProviderLogo, + NextURI: next, + } + + if loginErr != nil { + if e, ok := loginErr.(*echo.HTTPError); ok { + out.Error = e.Message.(string) + } else { + out.Error = loginErr.Error() + } + } + + // Generate and set a nonce for preventing CSRF requests. + nonce, err := utils.GenerateRandomString(16) + if err != nil { + app.log.Printf("error generating OIDC nonce: %v", err) + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.internalError")) + } + c.SetCookie(&http.Cookie{ + Name: "nonce", + Value: nonce, + HttpOnly: true, + Path: "/", + SameSite: http.SameSiteLaxMode, + }) + out.Nonce = nonce + + return c.Render(http.StatusOK, "admin-login", out) +} + +// doLogin logs a user in with a username and password. +func doLogin(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Verify that the request came from the login page (CSRF). + // nonce, err := c.Cookie("nonce") + // if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") { + // return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")) + // } + + var ( + username = strings.TrimSpace(c.FormValue("username")) + password = strings.TrimSpace(c.FormValue("password")) + ) + + if !strHasLen(username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + + if !strHasLen(password, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + + start := time.Now() + + user, err := app.core.LoginUser(username, password) + if err != nil { + return err + } + + // Resist potential constant-time-comparison attacks with a min response time. + if ms := time.Now().Sub(start).Milliseconds(); ms < 100 { + time.Sleep(time.Duration(ms)) + } + + // Set the session. + if err := app.auth.SaveSession(user, "", c); err != nil { + return err + } + + return nil +} diff --git a/cmd/handlers.go b/cmd/handlers.go index b031953bc..b630ab1c9 100644 --- a/cmd/handlers.go +++ b/cmd/handlers.go @@ -2,11 +2,12 @@ package main import ( "bytes" - "crypto/subtle" "net/http" + "net/url" "path" "regexp" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/paginator" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -18,6 +19,11 @@ const ( sortAsc = "asc" sortDesc = "desc" + + basicAuthd = "basicauthd" + + // URIs. + uriAdmin = "/admin" ) type okResp struct { @@ -47,16 +53,7 @@ var ( // registerHandlers registers HTTP handlers. func initHTTPHandlers(e *echo.Echo, app *App) { - // Group of private handlers with BasicAuth. - var g *echo.Group - - if len(app.constants.AdminUsername) == 0 || - len(app.constants.AdminPassword) == 0 { - g = e.Group("") - } else { - g = e.Group("", middleware.BasicAuth(basicAuth)) - } - + // Default error handler. e.HTTPErrorHandler = func(err error, c echo.Context) { // Generic, non-echo error. Log it. if _, ok := err.(*echo.HTTPError); !ok { @@ -65,166 +62,233 @@ func initHTTPHandlers(e *echo.Echo, app *App) { e.DefaultHTTPErrorHandler(err, c) } - // Admin JS app views. - // /admin/static/* file server is registered in initHTTPServer(). - e.GET("/", func(c echo.Context) error { - return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"}) - }) + var ( + // Authenticated /api/* handlers. + api = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + u := c.Get(auth.UserKey) - g.GET(path.Join(adminRoot, ""), handleAdminPage) - g.GET(path.Join(adminRoot, "/custom.css"), serveCustomAppearance("admin.custom_css")) - g.GET(path.Join(adminRoot, "/custom.js"), serveCustomAppearance("admin.custom_js")) - g.GET(path.Join(adminRoot, "/*"), handleAdminPage) + // On no-auth, respond with a JSON error. + if err, ok := u.(*echo.HTTPError); ok { + return err + } + + return next(c) + } + }) + + // Authenticated non /api handlers. + a = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + u := c.Get(auth.UserKey) + // On no-auth, redirect to login page + if _, ok := u.(*echo.HTTPError); ok { + u, _ := url.Parse(app.constants.LoginURL) + q := url.Values{} + q.Set("next", c.Request().RequestURI) + u.RawQuery = q.Encode() + return c.Redirect(http.StatusTemporaryRedirect, u.String()) + } + + return next(c) + } + }) + + // Public unauthenticated endpoints. + p = e.Group("") + ) + + // Authenticated endpoints. + a.GET(path.Join(uriAdmin, ""), handleAdminPage) + a.GET(path.Join(uriAdmin, "/custom.css"), serveCustomAppearance("admin.custom_css")) + a.GET(path.Join(uriAdmin, "/custom.js"), serveCustomAppearance("admin.custom_js")) + a.GET(path.Join(uriAdmin, "/*"), handleAdminPage) + + pm := app.auth.Perm // API endpoints. - g.GET("/api/health", handleHealthCheck) - g.GET("/api/config", handleGetServerConfig) - g.GET("/api/lang/:lang", handleGetI18nLang) - g.GET("/api/dashboard/charts", handleGetDashboardCharts) - g.GET("/api/dashboard/counts", handleGetDashboardCounts) - - g.GET("/api/settings", handleGetSettings) - g.PUT("/api/settings", handleUpdateSettings) - g.POST("/api/settings/smtp/test", handleTestSMTPSettings) - g.POST("/api/admin/reload", handleReloadApp) - g.GET("/api/logs", handleGetLogs) - g.GET("/api/about", handleGetAboutInfo) - - g.GET("/api/subscribers/:id", handleGetSubscriber) - g.GET("/api/subscribers/:id/export", handleExportSubscriberData) - g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces) - g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces) - 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) - - g.GET("/api/bounces", handleGetBounces) - g.GET("/api/bounces/:id", handleGetBounces) - g.DELETE("/api/bounces", handleDeleteBounces) - g.DELETE("/api/bounces/:id", handleDeleteBounces) + api.GET("/api/health", handleHealthCheck) + api.GET("/api/config", handleGetServerConfig) + api.GET("/api/lang/:lang", handleGetI18nLang) + api.GET("/api/dashboard/charts", handleGetDashboardCharts) + api.GET("/api/dashboard/counts", handleGetDashboardCounts) + + api.GET("/api/settings", pm(handleGetSettings, "settings:get")) + api.PUT("/api/settings", pm(handleUpdateSettings, "settings:manage")) + api.POST("/api/settings/smtp/test", pm(handleTestSMTPSettings, "settings:manage")) + api.POST("/api/admin/reload", pm(handleReloadApp, "settings:manage")) + api.GET("/api/logs", pm(handleGetLogs, "settings:get")) + api.GET("/api/events", pm(handleEventStream, "settings:get")) + api.GET("/api/about", handleGetAboutInfo) + + api.GET("/api/subscribers", pm(handleQuerySubscribers, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id", pm(handleGetSubscriber, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id/export", pm(handleExportSubscriberData, "subscribers:get_all", "subscribers:get")) + api.GET("/api/subscribers/:id/bounces", pm(handleGetSubscriberBounces, "bounces:get")) + api.DELETE("/api/subscribers/:id/bounces", pm(handleDeleteSubscriberBounces, "bounces:manage")) + api.POST("/api/subscribers", pm(handleCreateSubscriber, "subscribers:manage")) + api.PUT("/api/subscribers/:id", pm(handleUpdateSubscriber, "subscribers:manage")) + api.POST("/api/subscribers/:id/optin", pm(handleSubscriberSendOptin, "subscribers:manage")) + api.PUT("/api/subscribers/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) + api.PUT("/api/subscribers/:id/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) + api.PUT("/api/subscribers/lists/:id", pm(handleManageSubscriberLists, "subscribers:manage")) + api.PUT("/api/subscribers/lists", pm(handleManageSubscriberLists, "subscribers:manage")) + api.DELETE("/api/subscribers/:id", pm(handleDeleteSubscribers, "subscribers:manage")) + api.DELETE("/api/subscribers", pm(handleDeleteSubscribers, "subscribers:manage")) + + api.GET("/api/bounces", pm(handleGetBounces, "bounces:get")) + api.GET("/api/bounces/:id", pm(handleGetBounces, "bounces:get")) + api.DELETE("/api/bounces", pm(handleDeleteBounces, "bounces:manage")) + api.DELETE("/api/bounces/:id", pm(handleDeleteBounces, "bounces:manage")) // Subscriber operations based on arbitrary SQL queries. // These aren't very REST-like. - 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) - g.GET("/api/subscribers/export", - middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers)) - - 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) - - 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) - - g.GET("/api/campaigns", handleGetCampaigns) - g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats) - g.GET("/api/campaigns/:id", handleGetCampaign) - g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics) - g.GET("/api/campaigns/:id/preview", handlePreviewCampaign) - g.POST("/api/campaigns/:id/preview", handlePreviewCampaign) - g.POST("/api/campaigns/:id/content", handleCampaignContent) - g.POST("/api/campaigns/:id/text", 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.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive) - g.DELETE("/api/campaigns/:id", handleDeleteCampaign) - - g.GET("/api/media", handleGetMedia) - g.GET("/api/media/:id", handleGetMedia) - g.POST("/api/media", handleUploadMedia) - g.DELETE("/api/media/:id", handleDeleteMedia) - - 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) - - g.DELETE("/api/maintenance/subscribers/:type", handleGCSubscribers) - g.DELETE("/api/maintenance/analytics/:type", handleGCCampaignAnalytics) - g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions) - - g.POST("/api/tx", handleSendTxMessage) - - g.GET("/api/events", handleEventStream) + api.POST("/api/subscribers/query/delete", pm(handleDeleteSubscribersByQuery, "subscribers:manage")) + api.PUT("/api/subscribers/query/blocklist", pm(handleBlocklistSubscribersByQuery, "subscribers:manage")) + api.PUT("/api/subscribers/query/lists", pm(handleManageSubscriberListsByQuery, "subscribers:manage")) + api.GET("/api/subscribers/export", + pm(middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers), "subscribers:get_all", "subscribers:get")) + + api.GET("/api/import/subscribers", pm(handleGetImportSubscribers, "subscribers:import")) + api.GET("/api/import/subscribers/logs", pm(handleGetImportSubscriberStats, "subscribers:import")) + api.POST("/api/import/subscribers", pm(handleImportSubscribers, "subscribers:import")) + api.DELETE("/api/import/subscribers", pm(handleStopImportSubscribers, "subscribers:import")) + + // Individual list permissions are applied directly within handleGetLists. + api.GET("/api/lists", handleGetLists) + api.GET("/api/lists/:id", listPerm(handleGetLists)) + api.POST("/api/lists", pm(handleCreateList, "lists:manage_all")) + api.PUT("/api/lists/:id", listPerm(handleUpdateList)) + api.DELETE("/api/lists/:id", listPerm(handleDeleteLists)) + + api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get")) + api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get")) + api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get")) + api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics")) + api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) + api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) + api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage")) + api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage")) + api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage")) + api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage")) + api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage")) + api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage")) + api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage")) + api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage")) + + api.GET("/api/media", pm(handleGetMedia, "media:get")) + api.GET("/api/media/:id", pm(handleGetMedia, "media:get")) + api.POST("/api/media", pm(handleUploadMedia, "media:manage")) + api.DELETE("/api/media/:id", pm(handleDeleteMedia, "media:manage")) + + api.GET("/api/templates", pm(handleGetTemplates, "templates:get")) + api.GET("/api/templates/:id", pm(handleGetTemplates, "templates:get")) + api.GET("/api/templates/:id/preview", pm(handlePreviewTemplate, "templates:get")) + api.POST("/api/templates/preview", pm(handlePreviewTemplate, "templates:get")) + api.POST("/api/templates", pm(handleCreateTemplate, "templates:manage")) + api.PUT("/api/templates/:id", pm(handleUpdateTemplate, "templates:manage")) + api.PUT("/api/templates/:id/default", pm(handleTemplateSetDefault, "templates:manage")) + api.DELETE("/api/templates/:id", pm(handleDeleteTemplate, "templates:manage")) + + api.DELETE("/api/maintenance/subscribers/:type", pm(handleGCSubscribers, "settings:maintain")) + api.DELETE("/api/maintenance/analytics/:type", pm(handleGCCampaignAnalytics, "settings:maintain")) + api.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(handleGCSubscriptions, "settings:maintain")) + + api.POST("/api/tx", pm(handleSendTxMessage, "tx:send")) + + api.GET("/api/profile", handleGetUserProfile) + api.PUT("/api/profile", handleUpdateUserProfile) + api.GET("/api/users", pm(handleGetUsers, "users:get")) + api.GET("/api/users/:id", pm(handleGetUsers, "users:get")) + api.POST("/api/users", pm(handleCreateUser, "users:manage")) + api.PUT("/api/users/:id", pm(handleUpdateUser, "users:manage")) + api.DELETE("/api/users", pm(handleDeleteUsers, "users:manage")) + api.DELETE("/api/users/:id", pm(handleDeleteUsers, "users:manage")) + api.POST("/api/logout", handleLogout) + + api.GET("/api/roles/users", pm(handleGetUserRoles, "roles:get")) + api.GET("/api/roles/lists", pm(handleGeListRoles, "roles:get")) + api.POST("/api/roles/users", pm(handleCreateUserRole, "roles:manage")) + api.POST("/api/roles/lists", pm(handleCreateListRole, "roles:manage")) + api.PUT("/api/roles/users/:id", pm(handleUpdateUserRole, "roles:manage")) + api.PUT("/api/roles/lists/:id", pm(handleUpdateListRole, "roles:manage")) + api.DELETE("/api/roles/:id", pm(handleDeleteRole, "roles:manage")) if app.constants.BounceWebhooksEnabled { // Private authenticated bounce endpoint. - g.POST("/webhooks/bounce", handleBounceWebhook) + api.POST("/webhooks/bounce", pm(handleBounceWebhook, "webhooks:post_bounce")) // Public bounce endpoints for webservices like SES. - e.POST("/webhooks/service/:service", handleBounceWebhook) + p.POST("/webhooks/service/:service", handleBounceWebhook) } + // ================================================================= // Public API endpoints. - e.GET("/api/public/lists", handleGetPublicLists) - e.POST("/api/public/subscription", handlePublicSubscription) + // Landing page. + p.GET("/", func(c echo.Context) error { + return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"}) + }) + + // Public admin endpoints (login page, OIDC endpoints). + p.GET(path.Join(uriAdmin, "/login"), handleLoginPage) + p.POST(path.Join(uriAdmin, "/login"), handleLoginPage) + + if app.constants.Security.OIDC.Enabled { + p.POST("/auth/oidc", handleOIDCLogin) + p.GET("/auth/oidc", handleOIDCFinish) + } + + // Public APIs. + p.GET("/api/public/lists", handleGetPublicLists) + p.POST("/api/public/subscription", handlePublicSubscription) if app.constants.EnablePublicArchive { - e.GET("/api/public/archive", handleGetCampaignArchives) + p.GET("/api/public/archive", handleGetCampaignArchives) } // /public/static/* file server is registered in initHTTPServer(). // Public subscriber facing views. - e.GET("/subscription/form", handleSubscriptionFormPage) - e.POST("/subscription/form", handleSubscriptionForm) - e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), + p.GET("/subscription/form", handleSubscriptionFormPage) + p.POST("/subscription/form", handleSubscriptionForm) + p.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID"))) - e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs), + p.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs), "campUUID", "subUUID")) - e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID"))) - e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) - e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), + p.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID"))) + p.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) + p.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), "subUUID")) - e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), + p.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), "subUUID")) - e.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect, + p.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect, "linkUUID", "campUUID", "subUUID"))) - e.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage, + p.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage, "campUUID", "subUUID"))) - e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView, + p.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView, "campUUID", "subUUID"))) if app.constants.EnablePublicArchive { - e.GET("/archive", handleCampaignArchivesPage) - e.GET("/archive.xml", handleGetCampaignArchivesFeed) - e.GET("/archive/:id", handleCampaignArchivePage) - e.GET("/archive/latest", handleCampaignArchivePageLatest) + p.GET("/archive", handleCampaignArchivesPage) + p.GET("/archive.xml", handleGetCampaignArchivesFeed) + p.GET("/archive/:id", handleCampaignArchivePage) + p.GET("/archive/latest", handleCampaignArchivePageLatest) } - e.GET("/public/custom.css", serveCustomAppearance("public.custom_css")) - e.GET("/public/custom.js", serveCustomAppearance("public.custom_js")) + p.GET("/public/custom.css", serveCustomAppearance("public.custom_css")) + p.GET("/public/custom.js", serveCustomAppearance("public.custom_js")) // Public health API endpoint. - e.GET("/health", handleHealthCheck) + p.GET("/health", handleHealthCheck) // 404 pages. - e.RouteNotFound("/*", func(c echo.Context) error { + p.RouteNotFound("/*", func(c echo.Context) error { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl("404 - "+app.i18n.T("public.notFoundTitle"), "", "")) }) - e.RouteNotFound("/api/*", func(c echo.Context) error { + p.RouteNotFound("/api/*", func(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint") }) - e.RouteNotFound("/admin/*", func(c echo.Context) error { + p.RouteNotFound("/admin/*", func(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, "404 page not found") }) } @@ -233,7 +297,7 @@ func initHTTPHandlers(e *echo.Echo, app *App) { func handleAdminPage(c echo.Context) error { app := c.Get("app").(*App) - b, err := app.fs.Read(path.Join(adminRoot, "/index.html")) + b, err := app.fs.Read(path.Join(uriAdmin, "/index.html")) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } @@ -281,23 +345,6 @@ func serveCustomAppearance(name string) echo.HandlerFunc { } } -// 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/cmd/init.go b/cmd/init.go index 049f959e6..1b5d9de1c 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -3,6 +3,7 @@ package main import ( "bytes" "crypto/md5" + "database/sql" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/bounce/mailbox" "github.com/knadh/listmonk/internal/captcha" @@ -45,13 +47,11 @@ import ( "github.com/labstack/echo/v4" "github.com/lib/pq" flag "github.com/spf13/pflag" + "gopkg.in/volatiletech/null.v6" ) const ( queryFilePath = "queries.sql" - - // Root URI of the admin frontend. - adminRoot = "/admin" ) // constants contains static, constant config values required by the app. @@ -60,6 +60,7 @@ type constants struct { RootURL string `koanf:"root_url"` LogoURL string `koanf:"logo_url"` FaviconURL string `koanf:"favicon_url"` + LoginURL string `koanf:"login_url"` FromEmail string `koanf:"from_email"` NotifyEmails []string `koanf:"notify_emails"` EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` @@ -79,12 +80,17 @@ type constants struct { DomainBlocklist []string `koanf:"-"` } `koanf:"privacy"` Security struct { + OIDC struct { + Enabled bool `koanf:"enabled"` + Provider string `koanf:"provider_url"` + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"client_secret"` + } `koanf:"oidc"` + EnableCaptcha bool `koanf:"enable_captcha"` CaptchaKey string `koanf:"captcha_key"` CaptchaSecret string `koanf:"captcha_secret"` } `koanf:"security"` - AdminUsername []byte `koanf:"admin_username"` - AdminPassword []byte `koanf:"admin_password"` Appearance struct { AdminCSS []byte `koanf:"admin.custom_css"` @@ -93,13 +99,14 @@ type constants struct { PublicJS []byte `koanf:"public.custom_js"` } - UnsubURL string - LinkTrackURL string - ViewTrackURL string - OptinURL string - MessageURL string - ArchiveURL string - AssetVersion string + HasLegacyUser bool + UnsubURL string + LinkTrackURL string + ViewTrackURL string + OptinURL string + MessageURL string + ArchiveURL string + AssetVersion string MediaUpload struct { Provider string @@ -110,6 +117,9 @@ type constants struct { BounceSESEnabled bool BounceSendgridEnabled bool BouncePostmarkEnabled bool + + PermissionsRaw json.RawMessage + Permissions map[string]struct{} } type notifTpls struct { @@ -171,6 +181,7 @@ func initFS(appDir, frontendDir, staticDir, i18nDir string) stuffbin.FileSystem "./config.toml.sample:config.toml.sample", "./queries.sql:queries.sql", "./schema.sql:schema.sql", + "./permissions.json:permissions.json", } frontendFiles = []string{ @@ -385,11 +396,13 @@ func initConstants() *constants { if err := ko.Unmarshal("security", &c.Security); err != nil { lo.Fatalf("error loading app.security config: %v", err) } + if err := ko.UnmarshalWithConf("appearance", &c.Appearance, koanf.UnmarshalConf{FlatPaths: true}); err != nil { lo.Fatalf("error loading app.appearance config: %v", err) } c.RootURL = strings.TrimRight(c.RootURL, "/") + c.LoginURL = path.Join(uriAdmin, "/login") c.Lang = ko.String("app.lang") c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) c.MediaUpload.Provider = ko.String("upload.provider") @@ -420,9 +433,33 @@ func initConstants() *constants { c.BounceSendgridEnabled = ko.Bool("bounce.sendgrid_enabled") c.BouncePostmarkEnabled = ko.Bool("bounce.postmark.enabled") + c.HasLegacyUser = ko.Exists("app.admin_username") || ko.Exists("app.admin_password") + b := md5.Sum([]byte(time.Now().String())) c.AssetVersion = fmt.Sprintf("%x", b)[0:10] + pm, err := fs.Read("/permissions.json") + if err != nil { + lo.Fatalf("error reading permissions file: %v", err) + } + c.PermissionsRaw = pm + + // Make a lookup map of permissions. + permGroups := []struct { + Group string `json:"group"` + Permissions []string `json:"permissions"` + }{} + if err := json.Unmarshal(pm, &permGroups); err != nil { + lo.Fatalf("error loading permissions file: %v", err) + } + + c.Permissions = map[string]struct{}{} + for _, group := range permGroups { + for _, g := range group.Permissions { + c.Permissions[g] = struct{}{} + } + } + return &c } @@ -900,3 +937,72 @@ func initTplFuncs(i *i18n.I18n, cs *constants) template.FuncMap { return funcs } + +func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth { + var oidcCfg auth.OIDCConfig + + if ko.Bool("security.oidc.enabled") { + oidcCfg = auth.OIDCConfig{ + Enabled: true, + ProviderURL: ko.String("security.oidc.provider_url"), + ClientID: ko.String("security.oidc.client_id"), + ClientSecret: ko.String("security.oidc.client_secret"), + RedirectURL: fmt.Sprintf("%s/auth/oidc", strings.TrimRight(ko.String("app.root_url"), "/")), + } + } + + // Session manager callbacks for getting and setting cookies. + cb := &auth.Callbacks{ + GetCookie: func(name string, r interface{}) (*http.Cookie, error) { + c := r.(echo.Context) + cookie, err := c.Cookie(name) + return cookie, err + }, + SetCookie: func(cookie *http.Cookie, w interface{}) error { + c := w.(echo.Context) + c.SetCookie(cookie) + return nil + }, + GetUser: func(id int) (models.User, error) { + return co.GetUser(id, "", "") + }, + } + + a, err := auth.New(auth.Config{ + OIDC: oidcCfg, + }, db, cb, lo) + if err != nil { + lo.Fatalf("error initializing auth: %v", err) + } + + // Cache all API users in-memory for token auth. + if err := cacheAPIUsers(co, a); err != nil { + lo.Fatalf("error loading API users: %v", err) + } + + // If the legacy username+password is set in the TOML file, use that as an API + // access token in the auth module to preserve backwards compatibility for existing + // API integrations. The presence of these values show a red banner on the admin UI + // prompting the creation of new API credentials and the removal of values from + // the TOML config. + var ( + username = ko.String("app.admin_username") + password = ko.String("app.admin_password") + ) + if len(username) > 2 && len(password) > 6 { + u := models.User{ + Username: username, + Password: null.String{Valid: true, String: password}, + PasswordLogin: true, + HasPassword: true, + Status: models.UserStatusEnabled, + Type: models.UserTypeAPI, + } + u.UserRole.ID = auth.SuperAdminRoleID + a.CacheAPIUser(u) + + lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`) + } + + return a +} diff --git a/cmd/install.go b/cmd/install.go index 24c4527c8..58fc9f272 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -4,18 +4,17 @@ import ( "encoding/json" "fmt" "os" - "regexp" "strings" "github.com/gofrs/uuid/v5" "github.com/jmoiron/sqlx" + "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/knadh/stuffbin" "github.com/lib/pq" ) -// install runs the first time setup of creating and -// migrating the database and creating the super user. +// install runs the first time setup of setting up the database. func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempotent bool) { qMap := readQueries(queryFilePath, db, fs) @@ -63,6 +62,102 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo q := prepareQueries(qMap, db, ko) // Sample list. + defList, optinList := installLists(q) + + // Sample subscribers. + installSubs(defList, optinList, q) + + // Templates. + campTplID, archiveTplID := installTemplates(q) + + // Sample campaign. + installCampaign(campTplID, archiveTplID, q) + + // Super admin role. + user, password := installUser(q) + + lo.Printf("setup complete") + lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) + + if user != "" { + fmt.Printf("\n\033[31mIMPORTANT! CHANGE PASSWORD AFTER LOGGING IN\033[0m\nusername: \033[32m%s\033[0m and password: \033[32m%s\033[0m\n\n", user, password) + } +} + +// installSchema executes the SQL schema and creates the necessary tables and types. +func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { + q, err := fs.Read("/schema.sql") + if err != nil { + return err + } + + if _, err := db.Exec(string(q)); err != nil { + return err + } + + // Insert the current migration version. + return recordMigrationVersion(curVer, db) +} + +func installUser(q *models.Queries) (string, string) { + consts := initConstants() + + // Super admin role. + perms := []string{} + for p := range consts.Permissions { + perms = append(perms, p) + } + + if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil { + lo.Fatalf("error creating super admin role: %v", err) + } + + // Create super admin. + var ( + user = os.Getenv("LISTMONK_ADMIN_USER") + password = os.Getenv("LISTMONK_ADMIN_PASSWORD") + typ = "env" + ) + + if user != "" { + // If the env vars are set, use those values + if len(user) < 2 || len(password) < 8 { + lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars") + } + } else if ko.Exists("app.admin_username") { + // Legacy admin/password are set in the config or env var. Use those. + user = ko.String("app.admin_username") + password = ko.String("app.admin_password") + + if len(user) < 2 || len(password) < 8 { + lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars") + } + typ = "legacy config" + } else { + // None are set. Auto-generate. + user = "admin" + if p, err := utils.GenerateRandomString(12); err != nil { + lo.Fatal("error generating admin password") + } else { + password = p + } + typ = "auto-generated" + } + + lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ) + + if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, nil, "enabled"); err != nil { + lo.Fatalf("error creating superadmin user: %v", err) + } + + if typ == "auto-generated" { + return user, password + } + + return "", "" +} + +func installLists(q *models.Queries) (int, int) { var ( defList int optinList int @@ -88,13 +183,17 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating list: %v", err) } + return defList, optinList +} + +func installSubs(defListID, optinListID int, q *models.Queries) { // Sample subscriber. if _, err := q.UpsertSubscriber.Exec( uuid.Must(uuid.NewV4()), "john@example.com", "John Doe", `{"type": "known", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(defList)}, + pq.Int64Array{int64(defListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("Error creating subscriber: %v", err) @@ -104,12 +203,14 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo "anon@example.com", "Anon Doe", `{"type": "unknown", "good": true, "city": "Bengaluru"}`, - pq.Int64Array{int64(optinList)}, + pq.Int64Array{int64(optinListID)}, models.SubscriptionStatusUnconfirmed, true); err != nil { lo.Fatalf("error creating subscriber: %v", err) } +} +func installTemplates(q *models.Queries) (int, int) { // Default campaign template. campTpl, err := fs.Get("/static/email-templates/default.tpl") if err != nil { @@ -135,6 +236,20 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating default campaign template: %v", err) } + // Sample tx template. + txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") + if err != nil { + lo.Fatalf("error reading default e-mail template: %v", err) + } + + if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { + lo.Fatalf("error creating sample transactional template: %v", err) + } + + return campTplID, archiveTplID +} + +func installCampaign(campTplID, archiveTplID int, q *models.Queries) { // Sample campaign. if _, err := q.CreateCampaign.Exec(uuid.Must(uuid.NewV4()), models.CampaignTypeRegular, @@ -166,33 +281,6 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo lo.Fatalf("error creating sample campaign: %v", err) } - // Sample tx template. - txTpl, err := fs.Get("/static/email-templates/sample-tx.tpl") - if err != nil { - lo.Fatalf("error reading default e-mail template: %v", err) - } - - if _, err := q.CreateTemplate.Exec("Sample transactional template", models.TemplateTypeTx, "Welcome {{ .Subscriber.Name }}", txTpl.ReadBytes()); err != nil { - lo.Fatalf("error creating sample transactional template: %v", err) - } - - lo.Printf("setup complete") - lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address")) -} - -// installSchema executes the SQL schema and creates the necessary tables and types. -func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error { - q, err := fs.Read("/schema.sql") - if err != nil { - return err - } - - if _, err := db.Exec(string(q)); err != nil { - return err - } - - // Insert the current migration version. - return recordMigrationVersion(curVer, db) } // recordMigrationVersion inserts the given version (of DB migration) into the @@ -217,13 +305,6 @@ func newConfigFile(path string) 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 os.WriteFile(path, b, 0644) } diff --git a/cmd/lists.go b/cmd/lists.go index 7a8d34e79..0968b1213 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -5,15 +5,17 @@ import ( "strconv" "strings" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" ) -// handleGetLists retrieves lists with additional metadata like subscriber counts. This may be slow. +// handleGetLists retrieves lists with additional metadata like subscriber counts. func handleGetLists(c echo.Context) error { var ( - app = c.Get("app").(*App) - pg = app.paginator.NewFromURL(c.Request().URL.Query()) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) query = strings.TrimSpace(c.FormValue("query")) tags = c.QueryParams()["tag"] @@ -22,28 +24,23 @@ func handleGetLists(c echo.Context) error { optin = c.FormValue("optin") order = c.FormValue("order") minimal, _ = strconv.ParseBool(c.FormValue("minimal")) - listID, _ = strconv.Atoi(c.Param("id")) out models.PageResults ) - // Fetch one list. - single := false - if listID > 0 { - single = true - } - - if single { - out, err := app.core.GetList(listID, "") - if err != nil { - return err - } - return c.JSON(http.StatusOK, okResp{out}) + var ( + permittedIDs []int + getAll = false + ) + if _, ok := user.PermissionsMap["lists:get_all"]; ok { + getAll = true + } else { + permittedIDs = user.GetListIDs } // Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast. - if !single && minimal { - res, err := app.core.GetLists("") + if minimal { + res, err := app.core.GetLists("", getAll, permittedIDs) if err != nil { return err } @@ -61,20 +58,11 @@ func handleGetLists(c echo.Context) error { } // Full list query. - res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, pg.Offset, pg.Limit) + res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit) if err != nil { return err } - if single && len(res) == 0 { - return echo.NewHTTPError(http.StatusBadRequest, - app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}")) - } - - if single { - return c.JSON(http.StatusOK, okResp{res[0]}) - } - out.Query = query out.Results = res out.Total = total @@ -84,6 +72,21 @@ func handleGetLists(c echo.Context) error { return c.JSON(http.StatusOK, okResp{out}) } +// handleGetList retrieves a single list by id. +func handleGetList(c echo.Context) error { + var ( + app = c.Get("app").(*App) + listID, _ = strconv.Atoi(c.Param("id")) + ) + + out, err := app.core.GetList(listID, "") + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + // handleCreateList handles list creation. func handleCreateList(c echo.Context) error { var ( @@ -160,3 +163,37 @@ func handleDeleteLists(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } + +// listPerm is a middleware for wrapping /list/* API calls that take a +// list :id param for validating the list ID against the user's list perms. +func listPerm(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + var ( + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + id, _ = strconv.Atoi(c.Param("id")) + ) + + // Define permissions based on HTTP read/write. + var ( + permAll = models.PermListManageAll + perm = models.PermListManage + ) + if c.Request().Method == http.MethodGet { + permAll = models.PermListGetAll + perm = models.PermListGet + } + + // Check if the user has permissions for all lists or the specific list. + if _, ok := user.PermissionsMap[permAll]; ok { + return next(c) + } + if id > 0 { + if _, ok := user.ListPermissionsMap[id][perm]; ok { + return next(c) + } + } + + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.permissionDenied", "name", "list")) + } +} diff --git a/cmd/main.go b/cmd/main.go index 2d51f205e..82cc1d463 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,6 +15,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" "github.com/knadh/listmonk/internal/captcha" @@ -44,6 +45,7 @@ type App struct { manager *manager.Manager importer *subimporter.Importer messengers map[string]manager.Messenger + auth *auth.Auth media media.Store i18n *i18n.I18n bounce *bounce.Manager @@ -210,6 +212,7 @@ func main() { app.queries = queries app.manager = initCampaignManager(app.queries, app.constants, app) app.importer = initImporter(app.queries, db, app.core, app) + app.auth = initAuth(db.DB, ko, app.core) app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) initTxTemplates(app.manager, app) diff --git a/cmd/manager_store.go b/cmd/manager_store.go index 200eddf93..b1d67ef8c 100644 --- a/cmd/manager_store.go +++ b/cmd/manager_store.go @@ -20,6 +20,14 @@ type store struct { h *http.Client } +type runningCamp struct { + CampaignID int `db:"campaign_id"` + CampaignType string `db:"campaign_type"` + LastSubscriberID int `db:"last_subscriber_id"` + MaxSubscriberID int `db:"max_subscriber_id"` + ListID int `db:"list_id"` +} + func newManagerStore(q *models.Queries, c *core.Core, m media.Store) *store { return &store{ queries: q, @@ -42,8 +50,22 @@ func (s *store) NextCampaigns(currentIDs []int64, sentCounts []int64) ([]*models // and every batch takes the last ID of the last batch and fetches the next // batch above that. func (s *store) NextSubscribers(campID, limit int) ([]models.Subscriber, error) { + var camps []runningCamp + if err := s.queries.GetRunningCampaign.Select(&camps, campID); err != nil { + return nil, err + } + + var listIDs []int + for _, c := range camps { + listIDs = append(listIDs, c.ListID) + } + + if len(listIDs) == 0 { + return nil, nil + } + var out []models.Subscriber - err := s.queries.NextCampaignSubscribers.Select(&out, campID, limit) + err := s.queries.NextCampaignSubscribers.Select(&out, camps[0].CampaignID, camps[0].CampaignType, camps[0].LastSubscriberID, camps[0].MaxSubscriberID, pq.Array(listIDs), limit) return out, err } diff --git a/cmd/public.go b/cmd/public.go index 7216cb8b8..3a4084415 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -115,7 +115,7 @@ func handleGetPublicLists(c echo.Context) error { ) // Get all public lists. - lists, err := app.core.GetLists(models.ListTypePublic) + lists, err := app.core.GetLists(models.ListTypePublic, true, nil) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists")) } @@ -418,7 +418,7 @@ func handleSubscriptionFormPage(c echo.Context) error { } // Get all public lists. - lists, err := app.core.GetLists(models.ListTypePublic) + lists, err := app.core.GetLists(models.ListTypePublic, true, nil) if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) diff --git a/cmd/roles.go b/cmd/roles.go new file mode 100644 index 000000000..cf193cdc2 --- /dev/null +++ b/cmd/roles.go @@ -0,0 +1,216 @@ +package main + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" +) + +// handleGetUserRoles retrieves roles. +func handleGetUserRoles(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all roles. + out, err := app.core.GetRoles() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleGeListRoles retrieves roles. +func handleGeListRoles(c echo.Context) error { + var ( + app = c.Get("app").(*App) + ) + + // Get all roles. + out, err := app.core.GetListRoles() + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateUserRole handles role creation. +func handleCreateUserRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + r = models.Role{} + ) + + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateUserRole(r, app); err != nil { + return err + } + + out, err := app.core.CreateRole(r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateListRole handles role creation. +func handleCreateListRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + r = models.ListRole{} + ) + + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateListRole(r, app); err != nil { + return err + } + + out, err := app.core.CreateListRole(r) + if err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleUpdateUserRole handles role modification. +func handleUpdateUserRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 2 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var r models.Role + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateUserRole(r, app); err != nil { + return err + } + + // Validate. + r.Name.String = strings.TrimSpace(r.Name.String) + + out, err := app.core.UpdateUserRole(id, r) + if err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleUpdateListRole handles role modification. +func handleUpdateListRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 2 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var r models.ListRole + if err := c.Bind(&r); err != nil { + return err + } + + if err := validateListRole(r, app); err != nil { + return err + } + + // Validate. + r.Name.String = strings.TrimSpace(r.Name.String) + + out, err := app.core.UpdateListRole(id, r) + if err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleDeleteRole handles role deletion. +func handleDeleteRole(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.ParseInt(c.Param("id"), 10, 64) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if err := app.core.DeleteRole(int(id)); err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +func validateUserRole(r models.Role, app *App) error { + // Validate fields. + if !strHasLen(r.Name.String, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) + } + + for _, p := range r.Permissions { + if _, ok := app.constants.Permissions[p]; !ok { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("permission: %s", p))) + } + } + + return nil +} + +func validateListRole(r models.ListRole, app *App) error { + // Validate fields. + if !strHasLen(r.Name.String, 1, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "name")) + } + + for _, l := range r.Lists { + for _, p := range l.Permissions { + if p != "list:get" && p != "list:manage" { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", fmt.Sprintf("list permission: %s", p))) + } + } + } + + return nil +} diff --git a/cmd/settings.go b/cmd/settings.go index e6e03e2d8..dca7258d4 100644 --- a/cmd/settings.go +++ b/cmd/settings.go @@ -70,6 +70,7 @@ func handleGetSettings(c echo.Context) error { s.UploadS3AwsSecretAccessKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.UploadS3AwsSecretAccessKey)) s.SendgridKey = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SendgridKey)) s.SecurityCaptchaSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.SecurityCaptchaSecret)) + s.OIDC.ClientSecret = strings.Repeat(pwdMask, utf8.RuneCountInString(s.OIDC.ClientSecret)) s.BouncePostmark.Password = strings.Repeat(pwdMask, utf8.RuneCountInString(s.BouncePostmark.Password)) return c.JSON(http.StatusOK, okResp{s}) @@ -201,6 +202,9 @@ func handleUpdateSettings(c echo.Context) error { if set.SecurityCaptchaSecret == "" { set.SecurityCaptchaSecret = cur.SecurityCaptchaSecret } + if set.OIDC.ClientSecret == "" { + set.OIDC.ClientSecret = cur.OIDC.ClientSecret + } for n, v := range set.UploadExtensions { set.UploadExtensions[n] = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(v), ".")) diff --git a/cmd/subscribers.go b/cmd/subscribers.go index 46aad2db1..7947d3065 100644 --- a/cmd/subscribers.go +++ b/cmd/subscribers.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/subimporter" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" @@ -68,12 +69,17 @@ func handleGetSubscriber(c echo.Context) error { var ( app = c.Get("app").(*App) id, _ = strconv.Atoi(c.Param("id")) + user = c.Get(auth.UserKey).(models.User) ) if id < 1 { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) } + if err := hasSubPerm(user, []int{id}, app); err != nil { + return err + } + out, err := app.core.GetSubscriber(id, "", "") if err != nil { return err @@ -85,8 +91,9 @@ func handleGetSubscriber(c echo.Context) error { // handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression. func handleQuerySubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) - pg = app.paginator.NewFromURL(c.Request().URL.Query()) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pg = app.paginator.NewFromURL(c.Request().URL.Query()) // The "WHERE ?" bit. query = sanitizeSQLExp(c.FormValue("query")) @@ -96,10 +103,10 @@ func handleQuerySubscribers(c echo.Context) error { out models.PageResults ) - // Limit the subscribers to specific lists? - listIDs, err := getQueryInts("list_id", c.QueryParams()) + // Filter list IDs by permission. + listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + return err } res, total, err := app.core.QuerySubscribers(query, listIDs, subStatus, order, orderBy, pg.Offset, pg.Limit) @@ -119,16 +126,17 @@ func handleQuerySubscribers(c echo.Context) error { // handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression. func handleExportSubscribers(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) // The "WHERE ?" bit. query = sanitizeSQLExp(c.FormValue("query")) ) - // Limit the subscribers to specific lists? - listIDs, err := getQueryInts("list_id", c.QueryParams()) + // Filter list IDs by permission. + listIDs, err := filterListQeryByPerm(c.QueryParams(), user, app) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + return err } // Export only specific subscriber IDs? @@ -187,7 +195,9 @@ loop: // handleCreateSubscriber handles the creation of a new subscriber. func handleCreateSubscriber(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + req subimporter.SubReq ) @@ -202,8 +212,11 @@ func handleCreateSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.Lists, false, true) + // Insert the subscriber into the DB. - sub, _, err := app.core.InsertSubscriber(req.Subscriber, req.Lists, req.ListUUIDs, req.PreconfirmSubs) + sub, _, err := app.core.InsertSubscriber(req.Subscriber, listIDs, nil, req.PreconfirmSubs) if err != nil { return err } @@ -214,7 +227,9 @@ func handleCreateSubscriber(c echo.Context) error { // handleUpdateSubscriber handles modification of a subscriber. func handleUpdateSubscriber(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + id, _ = strconv.Atoi(c.Param("id")) req struct { models.Subscriber @@ -242,7 +257,10 @@ func handleUpdateSubscriber(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName")) } - out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, req.Lists, nil, req.PreconfirmSubs, true) + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.Lists, false, true) + + out, _, err := app.core.UpdateSubscriberWithLists(id, req.Subscriber, listIDs, nil, req.PreconfirmSubs, true) if err != nil { return err } @@ -318,7 +336,9 @@ func handleBlocklistSubscribers(c echo.Context) error { // It takes either an ID in the URI, or a list of IDs in the request body. func handleManageSubscriberLists(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + pID = c.Param("id") subIDs []int ) @@ -347,15 +367,18 @@ func handleManageSubscriberLists(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven")) } + // Filter lists against the current user's permitted lists. + listIDs := user.FilterListsByPerm(req.TargetListIDs, false, true) + // Action. var err error switch req.Action { case "add": - err = app.core.AddSubscriptions(subIDs, req.TargetListIDs, req.Status) + err = app.core.AddSubscriptions(subIDs, listIDs, req.Status) case "remove": - err = app.core.DeleteSubscriptions(subIDs, req.TargetListIDs) + err = app.core.DeleteSubscriptions(subIDs, listIDs) case "unsubscribe": - err = app.core.UnsubscribeLists(subIDs, req.TargetListIDs, nil) + err = app.core.UnsubscribeLists(subIDs, listIDs, nil) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } @@ -446,7 +469,9 @@ func handleBlocklistSubscribersByQuery(c echo.Context) error { // from one or more lists based on an arbitrary SQL expression. func handleManageSubscriberListsByQuery(c echo.Context) error { var ( - app = c.Get("app").(*App) + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + req subQueryReq ) @@ -458,15 +483,19 @@ func handleManageSubscriberListsByQuery(c echo.Context) error { app.i18n.T("subscribers.errorNoListsGiven")) } + // Filter lists against the current user's permitted lists. + sourceListIDs := user.FilterListsByPerm(req.ListIDs, false, true) + targetListIDs := user.FilterListsByPerm(req.TargetListIDs, false, true) + // Action. var err error switch req.Action { case "add": - err = app.core.AddSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.Status, req.SubscriptionStatus) + err = app.core.AddSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.Status, req.SubscriptionStatus) case "remove": - err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus) + err = app.core.DeleteSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) case "unsubscribe": - err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs, req.SubscriptionStatus) + err = app.core.UnsubscribeListsByQuery(req.Query, sourceListIDs, targetListIDs, req.SubscriptionStatus) default: return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction")) } @@ -632,3 +661,50 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i return len(lists), nil } } + +// hasSubPerm checks whether the current user has permission to access the given list +// of subscriber IDs. +func hasSubPerm(u models.User, subIDs []int, app *App) error { + if u.UserRoleID == auth.SuperAdminRoleID { + return nil + } + + if _, ok := u.PermissionsMap[models.PermSubscribersGetAll]; ok { + return nil + } + + res, err := app.core.HasSubscriberLists(subIDs, u.GetListIDs) + if err != nil { + return err + } + + for id, has := range res { + if !has { + return echo.NewHTTPError(http.StatusForbidden, app.i18n.Ts("globals.messages.permissionDenied", "name", fmt.Sprintf("subscriber: %d", id))) + } + } + + return nil +} + +func filterListQeryByPerm(qp url.Values, user models.User, app *App) ([]int, error) { + var listIDs []int + + // If there are incoming list query params, filter them by permission. + if qp.Has("list_id") { + ids, err := getQueryInts("list_id", qp) + if err != nil { + return nil, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + listIDs = user.FilterListsByPerm(ids, true, true) + } else { + // There are no incoming params. If the user doesn't have permission to get all subscribers, + // filter by the lists they have access to. + if _, ok := user.PermissionsMap[models.PermSubscribersGetAll]; !ok { + listIDs = user.GetListIDs + } + } + + return listIDs, nil +} diff --git a/cmd/updates.go b/cmd/updates.go index 3f792a3b9..cc092c36f 100644 --- a/cmd/updates.go +++ b/cmd/updates.go @@ -10,18 +10,25 @@ import ( "golang.org/x/mod/semver" ) -const updateCheckURL = "https://api.github.com/repos/knadh/listmonk/releases/latest" +const updateCheckURL = "https://update.listmonk.app/update.json" -type remoteUpdateResp struct { - Version string `json:"tag_name"` - URL string `json:"html_url"` -} - -// AppUpdate contains information of a new update available to the app that -// is sent to the frontend. type AppUpdate struct { - Version string `json:"version"` - URL string `json:"url"` + Update struct { + ReleaseVersion string `json:"release_version"` + ReleaseDate string `json:"release_date"` + URL string `json:"url"` + Description string `json:"description"` + + // This is computed and set locally based on the local version. + IsNew bool `json:"is_new"` + } `json:"update"` + Messages []struct { + Date string `json:"date"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + Priority string `json:"priority"` + } `json:"messages"` } var reSemver = regexp.MustCompile(`-(.*)`) @@ -32,48 +39,56 @@ var reSemver = regexp.MustCompile(`-(.*)`) func checkUpdates(curVersion string, interval time.Duration, app *App) { // Strip -* suffix. curVersion = reSemver.ReplaceAllString(curVersion, "") - time.Sleep(time.Second * 1) - ticker := time.NewTicker(interval) - defer ticker.Stop() - for range ticker.C { + fnCheck := func() { resp, err := http.Get(updateCheckURL) if err != nil { app.log.Printf("error checking for remote update: %v", err) - continue + return } if resp.StatusCode != 200 { app.log.Printf("non 200 response on remote update check: %d", resp.StatusCode) - continue + return } b, err := io.ReadAll(resp.Body) if err != nil { app.log.Printf("error reading remote update payload: %v", err) - continue + return } resp.Body.Close() - var up remoteUpdateResp - if err := json.Unmarshal(b, &up); err != nil { + var out AppUpdate + if err := json.Unmarshal(b, &out); err != nil { app.log.Printf("error unmarshalling remote update payload: %v", err) - continue + return } // There is an update. Set it on the global app state. - if semver.IsValid(up.Version) { - v := reSemver.ReplaceAllString(up.Version, "") + if semver.IsValid(out.Update.ReleaseVersion) { + v := reSemver.ReplaceAllString(out.Update.ReleaseVersion, "") if semver.Compare(v, curVersion) > 0 { - app.Lock() - app.update = &AppUpdate{ - Version: up.Version, - URL: up.URL, - } - app.Unlock() - - app.log.Printf("new update %s found", up.Version) + out.Update.IsNew = true + app.log.Printf("new update %s found", out.Update.ReleaseVersion) } } + + app.Lock() + app.update = &out + app.Unlock() + } + + // Give a 15 minute buffer after app start in case the admin wants to disable + // update checks entirely and not make a request to upstream. + time.Sleep(time.Minute * 15) + fnCheck() + + // Thereafter, check every $interval. + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + fnCheck() } } diff --git a/cmd/upgrade.go b/cmd/upgrade.go index 011ebbfd4..5dff65974 100644 --- a/cmd/upgrade.go +++ b/cmd/upgrade.go @@ -38,6 +38,7 @@ var migList = []migFunc{ {"v2.4.0", migrations.V2_4_0}, {"v2.5.0", migrations.V2_5_0}, {"v3.0.0", migrations.V3_0_0}, + {"v4.0.0", migrations.V4_0_0}, } // upgrade upgrades the database to the current version by running SQL migration files diff --git a/cmd/users.go b/cmd/users.go new file mode 100644 index 000000000..f01f99c4f --- /dev/null +++ b/cmd/users.go @@ -0,0 +1,288 @@ +package main + +import ( + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/knadh/listmonk/internal/auth" + "github.com/knadh/listmonk/internal/core" + "github.com/knadh/listmonk/internal/utils" + "github.com/knadh/listmonk/models" + "github.com/labstack/echo/v4" + "gopkg.in/volatiletech/null.v6" +) + +var ( + reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$") +) + +// handleGetUsers retrieves users. +func handleGetUsers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + userID, _ = strconv.Atoi(c.Param("id")) + ) + + // Fetch one. + single := false + if userID > 0 { + single = true + } + + if single { + out, err := app.core.GetUser(userID, "", "") + if err != nil { + return err + } + + out.Password = null.String{} + + return c.JSON(http.StatusOK, okResp{out}) + } + + // Get all users. + out, err := app.core.GetUsers() + if err != nil { + return err + } + + for n := range out { + out[n].Password = null.String{} + } + + return c.JSON(http.StatusOK, okResp{out}) +} + +// handleCreateUser handles user creation. +func handleCreateUser(c echo.Context) error { + var ( + app = c.Get("app").(*App) + u = models.User{} + ) + + if err := c.Bind(&u); err != nil { + return err + } + + u.Username = strings.TrimSpace(u.Username) + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if !strHasLen(u.Username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username + } + + // Create the user in the database. + user, err := app.core.CreateUser(u) + if err != nil { + return err + } + if user.Type != models.UserTypeAPI { + user.Password = null.String{} + } + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleUpdateUser handles user modification. +func handleUpdateUser(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.Atoi(c.Param("id")) + ) + + if id < 1 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + // Incoming params. + var u models.User + if err := c.Bind(&u); err != nil { + return err + } + + // Validate. + u.Username = strings.TrimSpace(u.Username) + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if !strHasLen(u.Username, 3, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + if !reUsername.MatchString(u.Username) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) + } + + if u.Type != models.UserTypeAPI { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + if u.PasswordLogin && u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + + if u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } else { + // Get the existing user for password validation. + user, err := app.core.GetUser(id, "", "") + if err != nil { + return err + } + + // If password login is enabled, but there's no password in the DB and there's no incoming + // password, throw an error. + if !user.HasPassword { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + } + + u.Email = null.String{String: email, Valid: true} + } + + if u.Name == "" { + u.Name = u.Username + } + + // Update the user in the DB. + user, err := app.core.UpdateUser(id, u) + if err != nil { + return err + } + + // Clear the pasword before sending outside. + user.Password = null.String{} + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleDeleteUsers handles user deletion, either a single one (ID in the URI), or a list. +func handleDeleteUsers(c echo.Context) error { + var ( + app = c.Get("app").(*App) + id, _ = strconv.ParseInt(c.Param("id"), 10, 64) + ids []int + ) + + if id < 1 && len(ids) == 0 { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID")) + } + + if id > 0 { + ids = append(ids, int(id)) + } + + if err := app.core.DeleteUsers(ids); err != nil { + return err + } + + // Cache the API token for validating API queries without hitting the DB every time. + if err := cacheAPIUsers(app.core, app.auth); err != nil { + return err + } + + return c.JSON(http.StatusOK, okResp{true}) +} + +// handleGetUserProfile fetches the uesr profile for the currently logged in user. +func handleGetUserProfile(c echo.Context) error { + var ( + user = c.Get(auth.UserKey).(models.User) + ) + user.Password.String = "" + user.Password.Valid = false + + return c.JSON(http.StatusOK, okResp{user}) +} + +// handleUpdateUserProfile update's the current user's profile. +func handleUpdateUserProfile(c echo.Context) error { + var ( + app = c.Get("app").(*App) + user = c.Get(auth.UserKey).(models.User) + ) + + u := models.User{} + if err := c.Bind(&u); err != nil { + return err + } + u.PasswordLogin = user.PasswordLogin + u.Name = strings.TrimSpace(u.Name) + email := strings.TrimSpace(u.Email.String) + + // Validate fields. + if user.PasswordLogin { + if !utils.ValidateEmail(email) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email")) + } + u.Email = null.String{String: email, Valid: true} + } + + if u.PasswordLogin && u.Password.String != "" { + if !strHasLen(u.Password.String, 8, stdInputMaxLen) { + return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) + } + } + + out, err := app.core.UpdateUserProfile(user.ID, u) + if err != nil { + return err + } + out.Password = null.String{} + + return c.JSON(http.StatusOK, okResp{out}) +} + +func cacheAPIUsers(co *core.Core, a *auth.Auth) error { + allUsers, err := co.GetUsers() + if err != nil { + return err + } + + apiUsers := make([]models.User, 0, len(allUsers)) + for _, u := range allUsers { + if u.Type == models.UserTypeAPI && u.Status == models.UserStatusEnabled { + apiUsers = append(apiUsers, u) + } + } + + a.CacheAPIUsers(apiUsers) + return nil +} diff --git a/config.toml.sample b/config.toml.sample index 89e0f0992..80d53a3f3 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -5,13 +5,6 @@ # port, use port 80 (this will require running with elevated permissions). address = "localhost: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 = "localhost" diff --git a/docker-compose.yml b/docker-compose.yml index 677bbdcce..38062bcc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ x-app-defaults: &app-defaults - TZ=Etc/UTC x-db-defaults: &db-defaults - image: postgres:13-alpine + image: postgres:14-alpine ports: - "9432:5432" networks: diff --git a/docs/docs/content/configuration.md b/docs/docs/content/configuration.md index 6944a6586..11e7244fd 100644 --- a/docs/docs/content/configuration.md +++ b/docs/docs/content/configuration.md @@ -13,8 +13,6 @@ Example: | **Environment variable** | Example value | | ------------------------------ | -------------- | | `LISTMONK_app__address` | "0.0.0.0:9000" | -| `LISTMONK_app__admin_username` | listmonk | -| `LISTMONK_app__admin_password` | listmonk | | `LISTMONK_db__host` | db | | `LISTMONK_db__port` | 9432 | | `LISTMONK_db__user` | listmonk | @@ -141,8 +139,8 @@ with any Timezone listed [here](https://en.wikipedia.org/wiki/List_of_tz_databas ### Retries The `Settings -> SMTP -> Retries` denotes the number of times a message that fails at the moment of sending is retried silently using different connections from the SMTP pool. The messages that fail even after retries are the ones that are logged as errors and ignored. -### Blocked Ports -Some server hosts block SMTP ports (25, 465) so you have to get request to unblock them i.e. [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server). +## SMTP ports +Some server hosts block outgoing SMTP ports (25, 465). You may have to contact your host to unblock them before being able to send e-mails. Eg: [Hetzner](https://docs.hetzner.com/cloud/servers/faq/#why-can-i-not-send-any-mails-from-my-server). ## Performance diff --git a/docs/docs/content/installation.md b/docs/docs/content/installation.md index 90647c0c9..3d545c442 100644 --- a/docs/docs/content/installation.md +++ b/docs/docs/content/installation.md @@ -1,14 +1,16 @@ # Installation -listmonk requires Postgres ⩾ 12. +listmonk requires Postgres ⩾ 12 -See the "[Tutorials](#tutorials)" section at the bottom for detailed guides. +!!! Admin + listmonk generates and prints admin credentials to the terminal during installation. This can be copied to login to the admin dashboard and later changed. To choose a custom username and password during installation, + set the environment variables `LISTMONK_ADMIN_USER` and `LISTMONK_ADMIN_PASSWORD` during installation. ## Binary -- Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. `amd64` is the main one. It works for Intel and x86 CPUs. -- `./listmonk --new-config` to generate config.toml. Then, edit the file. -- `./listmonk --install` to install the tables in the Postgres DB. -- Run `./listmonk` and visit `http://localhost:9000`. +1. Download the [latest release](https://github.com/knadh/listmonk/releases) and extract the listmonk binary. `amd64` is the main one. It works for Intel and x86 CPUs. +1. `./listmonk --new-config` to generate config.toml. Edit the file. +1. `./listmonk --install` to install the tables in the Postgres DB. Copy the admin username and password from the terminal output (these can be changed from the admin UI later). To choose a custom username and password during installation, run: `LISTMONK_ADMIN_USER=myuser LISTMONK_ADMIN_PASSWORD=xxxxx ./listmonk --install` +1. Run `./listmonk` and visit `http://localhost:9000`. ## Docker @@ -16,7 +18,7 @@ See the "[Tutorials](#tutorials)" section at the bottom for detailed guides. The latest image is available on DockerHub at `listmonk/listmonk:latest` !!! note - Listmonk's docs and scripts use `docker compose`, which is compatible with the latest version of docker. If you installed docker and docker-compose from your Linux distribution, you probably have an older version and will need to use the `docker-compose` command instead, or you'll need to update docker manually. [More info](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11#docker-update). + listmonk's docs and scripts use `docker compose`, which is compatible with the latest version of docker. If you installed docker and docker-compose from your Linux distribution, you probably have an older version and will need to use the `docker-compose` command instead, or you'll need to update docker manually. [More info](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11#docker-update). 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: @@ -61,7 +63,7 @@ The above shell script performs the following actions: #### Manual Docker install -The following workflow is recommended to setup `listmonk` manually using `docker compose`. You are encouraged to customise the contents of `docker-compose.yml` to your needs. The overall setup looks like: +The following workflow is recommended to setup `listmonk` manually using `docker compose`. You are encouraged to customise the contents of [`docker-compose.yml`](https://github.com/knadh/listmonk/blob/master/docker-compose.yml) to your needs. The overall setup looks like: - `docker compose up db` to run the Postgres DB. - `docker compose run --rm app ./listmonk --install` to setup the DB (or `--upgrade` to upgrade an existing DB). @@ -94,8 +96,6 @@ Here's a sample `config.toml` you can use: ```toml [app] address = "0.0.0.0:9000" -admin_username = "listmonk" -admin_password = "listmonk" # Database. [db] @@ -177,9 +177,9 @@ $ helm upgrade \ ## Tutorials -* [Informal step-by-step on how to get started with Listmonk using *Railway*](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533) +* [Informal step-by-step on how to get started with listmonk using *Railway*](https://github.com/knadh/listmonk/issues/120#issuecomment-1421838533) * [Step-by-step tutorial for installation and all basic functions. *Amazon EC2, SES, docker & binary*](https://gist.github.com/MaximilianKohler/e5158fcfe6de80a9069926a67afcae11) -* [Step-by-step guide on how to install and set up Listmonk on *AWS Lightsail with docker* (rameerez)](https://github.com/knadh/listmonk/issues/1208) +* [Step-by-step guide on how to install and set up listmonk on *AWS Lightsail with docker* (rameerez)](https://github.com/knadh/listmonk/issues/1208) * [Quick setup on any cloud server using *docker and caddy*](https://github.com/samyogdhital/listmonk-caddy-reverse-proxy) * [*Binary* install on Ubuntu 22.04 as a service](https://mumaritc.hashnode.dev/how-to-install-listmonk-using-binary-on-ubuntu-2204) * [*Binary* install on Ubuntu 18.04 as a service (Apache & Plesk)](https://devgypsy.com/post/2020-08-18-installing-listmonk-newsletter-manager/) diff --git a/docs/docs/content/upgrade.md b/docs/docs/content/upgrade.md index 2c8371ced..8c7ff096f 100644 --- a/docs/docs/content/upgrade.md +++ b/docs/docs/content/upgrade.md @@ -58,3 +58,14 @@ x-app-defaults: &app-defaults 4. Restart: `sudo docker compose up -d app db nginx certbot` + +## Upgrading to v4.x.x +v4 is a major upgrade from prior versions with significant changes to certain important features and behaviour. It is the first version to have multi-user support and full fledged user management. Prior versions only had a simple BasicAuth for both admin login (browser prompt) and API calls, with the username and password defined in the TOML configuration file. + +It is safe to upgrade an older installation with `--upgrade`, but there are a few important things to keep in mind. The upgrade automatically imports the `admin_username` and `admin_password` defined in the TOML configuration into the new user management system. + +1. **New login UI**: Once you upgrade an older installation, the admin dashboard will no longer show the native browser prompt for login. Instead, a new login UI rendered by listmonk is displayed at the URI `/admin/login`. + +1. **API credentials**: If you are using APIs to interact with listmonk, after logging in, go to Settings -> Users and create a new API user with the necessary permissions. Change existing API integrations to use these credentials instead of the old username and password defined in the legacy TOML configuration file or environment variables. + +1. **Credentials in TOML file or old environment variables**: The admin dashboard shows a warning until the `admin_username` and `admin_password` fields are removed from the configuration file or old environment variables. In v4.x.x, these are irrelevant as user credentials are stored in the database and managed from the admin UI. IMPORTANT: if you are using APIs to interact with listmonk, follow the previous step before removing the legacy credentials. diff --git a/docs/docs/mkdocs.yml b/docs/docs/mkdocs.yml index 078d8f5c7..0534eb601 100644 --- a/docs/docs/mkdocs.yml +++ b/docs/docs/mkdocs.yml @@ -44,7 +44,7 @@ nav: - "Installation": installation.md - "Configuration": configuration.md - "Upgrade": upgrade.md - - "Using Listmonk": + - "Using listmonk": - "Concepts": concepts.md - "Templating": templating.md - "Querying and segmenting subscribers": querying-and-segmentation.md diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index ce9a9faf6..54e014fda 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { 'vue/max-attributes-per-line': 'off', 'vue/html-indent': 'off', 'vue/html-closing-bracket-newline': 'off', + 'vue/singleline-html-element-content-newline': 'off', 'vue/max-len': ['error', { code: 200, template: 200, diff --git a/frontend/fontello/config.json b/frontend/fontello/config.json index 3a3782c80..9cb2d79c2 100755 --- a/frontend/fontello/config.json +++ b/frontend/fontello/config.json @@ -594,6 +594,26 @@ "code" ] }, + { + "uid": "8f28d948aa6379b1a69d2a090e7531d4", + "css": "warning-empty", + "code": 59431, + "src": "typicons" + }, + { + "uid": "77025195d19e048302e8943e2da4cc75", + "css": "account-outline", + "code": 983059, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z", + "width": 1000 + }, + "search": [ + "account-outline" + ] + }, { "uid": "f4ad3f6d071a0bfb3a8452b514ed0892", "css": "vector-square", @@ -832,20 +852,6 @@ "account-off" ] }, - { - "uid": "77025195d19e048302e8943e2da4cc75", - "css": "account-outline", - "code": 983059, - "src": "custom_icons", - "selected": false, - "svg": { - "path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z", - "width": 1000 - }, - "search": [ - "account-outline" - ] - }, { "uid": "571120b7ff63feb71df85710d019302c", "css": "account-plus", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e03134fb8..2eca5d161 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,9 +12,29 @@ @@ -33,7 +53,8 @@
-
+
{{ $t('settings.needsRestart') }} — @@ -42,9 +63,35 @@ {{ $t('settings.restart') }}
-
- {{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }} - View + + + +
+ + Remove the admin_username and admin_password fields from the TOML + configuration file or environment variables. If you are using APIs, create and use new API credentials + before removing the them. Visit + + Admin -> Settings -> Users + dashboard. Learn more.
@@ -114,17 +161,9 @@ export default Vue.extend({ }, doLogout() { - const http = new XMLHttpRequest(); - - const u = uris.root.substr(-1) === '/' ? uris.root : `${uris.root}/`; - http.open('get', `${u}api/logout`, false, 'logout_non_user', 'logout_non_user'); - http.onload = () => { + this.$api.logout().then(() => { document.location.href = uris.root; - }; - http.onerror = () => { - document.location.href = uris.root; - }; - http.send(); + }); }, listenEvents() { @@ -147,7 +186,7 @@ export default Vue.extend({ }, computed: { - ...mapState(['serverConfig']), + ...mapState(['serverConfig', 'profile']), version() { return import.meta.env.VUE_APP_VERSION; diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 34a2e07b0..5f4f11a65 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -422,9 +422,7 @@ export const getLang = async (lang) => http.get( { loading: models.lang, camelCase: false }, ); -export const logout = async () => http.get('/api/logout', { - auth: { username: 'wrong', password: 'wrong' }, -}); +export const logout = async () => http.post('/api/logout'); export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete( `/api/maintenance/analytics/${typ}`, @@ -440,3 +438,92 @@ export const deleteGCSubscriptions = async (beforeDate) => http.delete( '/api/maintenance/subscriptions/unconfirmed', { loading: models.maintenance, params: { before_date: beforeDate } }, ); + +// Users. +export const getUsers = () => http.get( + '/api/users', + { + loading: models.users, + store: models.users, + }, +); + +export const queryUsers = () => http.get( + '/api/users', + { + loading: models.users, + store: models.users, + }, +); + +export const getUser = async (id) => http.get( + `/api/users/${id}`, + { loading: models.users }, +); + +export const createUser = (data) => http.post( + '/api/users', + data, + { loading: models.users }, +); + +export const updateUser = (data) => http.put( + `/api/users/${data.id}`, + data, + { loading: models.users }, +); + +export const deleteUser = (id) => http.delete( + `/api/users/${id}`, + { loading: models.users }, +); + +export const getUserProfile = () => http.get( + '/api/profile', + { loading: models.users, store: models.profile }, +); + +export const updateUserProfile = (data) => http.put( + '/api/profile', + data, + { loading: models.users, store: models.profile }, +); + +export const getUserRoles = async () => http.get( + '/api/roles/users', + { loading: models.userRoles, store: models.userRoles }, +); + +export const getListRoles = async () => http.get( + '/api/roles/lists', + { loading: models.listRoles, store: models.listRoles }, +); + +export const createUserRole = (data) => http.post( + '/api/roles/users', + data, + { loading: models.userRoles }, +); + +export const createListRole = (data) => http.post( + '/api/roles/lists', + data, + { loading: models.listRoles }, +); + +export const updateUserRole = (data) => http.put( + `/api/roles/users/${data.id}`, + data, + { loading: models.userRoles }, +); + +export const updateListRole = (data) => http.put( + `/api/roles/lists/${data.id}`, + data, + { loading: models.userRoles }, +); + +export const deleteRole = (id) => http.delete( + `/api/roles/${id}`, + { loading: models.userRoles }, +); diff --git a/frontend/src/assets/icons/fontello.css b/frontend/src/assets/icons/fontello.css index 9078caaac..ee4874e2e 100644 --- a/frontend/src/assets/icons/fontello.css +++ b/frontend/src/assets/icons/fontello.css @@ -40,6 +40,41 @@ /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ } +[class^="mdi-"]:before, [class*=" mdi-"]:before { + font-family: "fontello"; + font-style: normal; + font-weight: normal; + speak: never; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* you can be more comfortable with increased icons size */ + /* font-size: 120%; */ + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ +} + .mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */ .mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */ @@ -80,6 +115,8 @@ .mdi-chart-bar:before { content: '\e824'; } /* '' */ .mdi-email-bounce:before { content: '\e825'; } /* '' */ .mdi-speedometer:before { content: '\e826'; } /* '' */ +.mdi-warning-empty:before { content: '\e827'; } /* '' */ +.mdi-account-outline:before { content: '󰀓'; } /* '\f0013' */ +.mdi-code:before { content: '󰅩'; } /* '\f0169' */ .mdi-logout-variant:before { content: '󰗽'; } /* '\f05fd' */ .mdi-wrench-outline:before { content: '󰯠'; } /* '\f0be0' */ -.mdi-code:before { content: '󰅩'; } /* '\f0169' */ diff --git a/frontend/src/assets/icons/fontello.woff2 b/frontend/src/assets/icons/fontello.woff2 index 2875eca2d..5f729eea5 100755 Binary files a/frontend/src/assets/icons/fontello.woff2 and b/frontend/src/assets/icons/fontello.woff2 differ diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 1b8c82a69..caec11729 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -24,7 +24,7 @@ $body-size: 15px; $background: $white-bis; $body-background-color: $white-bis; $primary: #0055d4; -$green: #0db35e; +$green: #36995b; $turquoise: $green; $red: #FF5722; @@ -88,6 +88,10 @@ section { &.wrap { max-width: 1100px; } + + &.section-mini { + max-width: 500px; + } } .spinner.is-tiny { @@ -131,6 +135,41 @@ section { background-color: #efefef; } +.navbar-item.user { + .navbar-link:not(.is-arrowless)::after { + margin-top: -0.6rem; + } + + .user-name { + display: block; + min-width: 150px; + } +} + +.user-avatar { + img { + display: inline-block; + border-radius: 100%; + width: 32px; + height: 32px; + max-height: none; + } + span { + background-color: #ddd; + border-radius: 100%; + width: 32px; + height: 32px; + text-align: center; + display: inline-block; + font-size: 0.875rem; + font-weight: bold; + + display: flex; + justify-content: center; + align-items: center; + } +} + .copy-text { color: inherit; .icon { @@ -145,6 +184,11 @@ section { } } +.spaced-links a { + margin-right: 15px; + display: inline-block; +} + /* Two column sidebar+body layout */ #app { min-height: 100%; @@ -228,7 +272,7 @@ body.is-noscroll { } .notification { padding: 10px 15px; - border-left: 5px solid #eee; + border-left: 10px solid #eee; &.is-danger { background: $white-ter; @@ -240,6 +284,11 @@ body.is-noscroll { color: $black; border-left-color: $green; } + &.is-info { + background: $white-ter; + border-left-color: $primary; + color: $grey-dark; + } } /* WYSIWYG / HTML code editor */ @@ -545,51 +594,42 @@ body.is-noscroll { /* Tags */ .tag { - min-width: 85px; - + border-radius: 30px !important; + border: 0; + padding: 0 20px !important; + &.is-small { font-size: 0.65rem; background: $white-ter; - border: 1px solid $white-ter; + // border: 1px solid $white-ter; padding: 3px 5px; min-width: auto !important; } - + &:not(body) { + background-color: #eee; font-size: 0.85em; - $color: $grey-lighter; - border: 1px solid $color; - box-shadow: 1px 1px 0 $color; color: $grey; } - &.private, &.scheduled, &.paused, &.tx { + &.private, &.scheduled, &.paused, &.tx, &.api { $color: #ed7b00; color: $color; - background: #fff7e6; - border: 1px solid lighten($color, 37%); - box-shadow: 1px 1px 0 lighten($color, 37%); + background: lighten($color, 47); } - &.public, &.running, &.list, &.campaign { + &.public, &.running, &.list, &.campaign, &.user, &.primary { $color: $primary; - color: lighten($color, 20%);; + color: lighten($color, 20%); background: #e6f7ff; - border: 1px solid lighten($color, 42%); - box-shadow: 1px 1px 0 lighten($color, 42%); } &.finished, &.enabled, &.status-confirmed { - $color: $green; - color: $color; - background: #f6ffed; - border: 1px solid lighten($color, 45%); - box-shadow: 1px 1px 0 lighten($color, 45%); + color: $green; + background: #dcfce7; } &.blocklisted, &.cancelled, &.status-unsubscribed { $color: $red; color: $color; background: #fff1f0; - border: 1px solid lighten($color, 30%); - box-shadow: 1px 1px 0 lighten($color, 30%); } sup { @@ -693,6 +733,10 @@ section.lists { .toggle-advanced { margin-top: 10px; } + + .blocklisted { + color: red; + } } .b-table.subscriptions { @@ -856,10 +900,6 @@ section.analytics { height: auto; min-height: 350px; } - .smtp-shortcuts a { - margin-right: 15px; - display: inline-block; - } } /* Logs */ @@ -891,6 +931,38 @@ section.analytics { } } +/* Users */ +section.users { + td .tag { + margin: 0 3px; + } +} +.user-api-token .copy-text { + background: rgba($green, .1); + display: block; + width: 100%; + border-radius: 3px; + padding: 15px; + font-size: 1.2rem; + color: $green; +} + +.permissions-group { + display: flex; + flex-wrap: wrap; + gap: 10px; + + label { + flex: 1 1 45%; + max-width: 45%; + display: flex; + } +} + +th.role-toggle-select a { + font-weight: normal; +} + /* C3 charting lib */ .c3 { .c3-text.c3-empty { diff --git a/frontend/src/components/ListSelector.vue b/frontend/src/components/ListSelector.vue index dc25fe406..53610caec 100644 --- a/frontend/src/components/ListSelector.vue +++ b/frontend/src/components/ListSelector.vue @@ -4,7 +4,10 @@ - {{ l.name }} {{ $t(`subscribers.status.${l.subscriptionStatus}`) }} + {{ l.name }} + + {{ $t(`subscribers.status.${l.subscriptionStatus}`) }} +
diff --git a/frontend/src/components/Navigation.vue b/frontend/src/components/Navigation.vue index f4852e02d..340769a85 100644 --- a/frontend/src/components/Navigation.vue +++ b/frontend/src/components/Navigation.vue @@ -12,40 +12,56 @@ icon="newspaper-variant-outline" :label="$t('menu.forms')" /> - - - - + + + - - - - - - + + + + + - - - - + + + + + + + + + + @@ -53,6 +69,8 @@ diff --git a/frontend/src/views/Roles.vue b/frontend/src/views/Roles.vue new file mode 100644 index 000000000..f51f02027 --- /dev/null +++ b/frontend/src/views/Roles.vue @@ -0,0 +1,193 @@ + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue index bc412328c..6f5193501 100644 --- a/frontend/src/views/Settings.vue +++ b/frontend/src/views/Settings.vue @@ -10,7 +10,7 @@
- + {{ $t('globals.buttons.save') }} @@ -160,6 +160,12 @@ export default Vue.extend({ hasDummy = 'captcha'; } + if (this.isDummy(form['security.oidc.client_secret'])) { + form['security.oidc.client_secret'] = ''; + } else if (this.hasDummy(form['security.oidc.client_secret'])) { + hasDummy = 'oidc'; + } + if (this.isDummy(form['bounce.postmark'].password)) { form['bounce.postmark'].password = ''; } else if (this.hasDummy(form['bounce.postmark'].password)) { diff --git a/frontend/src/views/SubscriberForm.vue b/frontend/src/views/SubscriberForm.vue index bb027688b..8fe84c193 100644 --- a/frontend/src/views/SubscriberForm.vue +++ b/frontend/src/views/SubscriberForm.vue @@ -33,7 +33,8 @@
- + @@ -55,7 +56,7 @@
-
+
{{ $t('subscribers.sendOptinConfirm') }} @@ -146,7 +147,8 @@ {{ $t('globals.buttons.close') }} - + {{ $t('globals.buttons.save') }} diff --git a/frontend/src/views/Subscribers.vue b/frontend/src/views/Subscribers.vue index 1e13168ec..9da703753 100644 --- a/frontend/src/views/Subscribers.vue +++ b/frontend/src/views/Subscribers.vue @@ -13,7 +13,7 @@
- + {{ $t('globals.buttons.new') }} @@ -42,7 +42,8 @@ data-cy="query" /> {{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }} - + {{ $t('globals.buttons.learnMore') }}. @@ -69,8 +70,8 @@
- - - - - {{ $t(`subscribers.status.${props.row.status}`) }} - - - - - + {{ props.row.email }} + + {{ $t(`subscribers.status.${props.row.status}`) }} +