Compare commits

...

64 commits

Author SHA1 Message Date
Kailash Nadh 65f36f63d2 Refactor subscriber APIs list permission filtering. 2024-09-16 23:00:59 +05:30
Kailash Nadh 20502c029c Rename migration to v4.0.0 2024-09-16 23:00:59 +05:30
Kailash Nadh 3cbf67b943 Sory users by created_at always. 2024-09-16 23:00:59 +05:30
Kailash Nadh 83d3af3527 Fix list auth by adding an explicit 'getAll' flag to query. 2024-09-16 23:00:59 +05:30
Kailash Nadh 806e499e87 Update profile UI with new user data structures. 2024-09-16 23:00:59 +05:30
Kailash Nadh 95f63a13af Add support for "list roles".
This commit splits roles into two, user roles and list roles, both of which
are attached separately to a user.

List roles are collection of lists each with read|write permissions, while
user roles now have all permissions except for per-list ones.

This allows for easier management of roles, eliminating the need to clone and
create new roles just to adjust specific list permissions.
2024-09-16 23:00:59 +05:30
Kailash Nadh 601e1e6e65 Add list permission check to subscriber calls. 2024-09-16 23:00:59 +05:30
Kailash Nadh b667a9230a Add per-list permission to list management.
- Filter lists by permitted list IDs in DB get calls.
- Split getLists() handlers into two (one, all) for clarity.
- Introduce new `subscribers:get_by_list` permission.
- Tweak UI rendering to work with new per-list permssions.
2024-09-16 23:00:59 +05:30
Kailash Nadh 62d9a39e80 Fix post v4.x.x upgrade warning on admin UI. 2024-09-16 23:00:59 +05:30
Kailash Nadh d3d8908de2 Add docs for v4.x.x multi-user upgrade changes. 2024-09-16 23:00:59 +05:30
Kailash Nadh 55bd98630a Remove admin user/password from sample config generation. 2024-09-16 23:00:59 +05:30
Kailash Nadh c6281b987b Fix logic for preventing sole super admin from being wrongly updated/deleted. 2024-09-16 23:00:59 +05:30
Kailash Nadh 316d574d80 Add support for setting admin user/password via env on --install. 2024-09-16 23:00:59 +05:30
Kailash Nadh e4a7d307b3 Fix update check looping on failed HTTP requests. 2024-09-16 23:00:59 +05:30
Kailash Nadh 862d4240c5 Add legacy TOML user+password to API auth on init with warning. 2024-09-16 23:00:59 +05:30
Kailash Nadh af63c1628e Add API user authentication to auth module with caching of creds on user CRUD. 2024-09-16 23:00:59 +05:30
Kailash Nadh d33341f731 Fix role selection on in user form. 2024-09-16 23:00:59 +05:30
Kailash Nadh 6236c42c12 User legacy (TOML) admin credentials as API creds for backwards compatibility. 2024-09-16 23:00:59 +05:30
Kailash Nadh ba1e74540a Fix admin UI legacy user warning. 2024-09-16 23:00:59 +05:30
Kailash Nadh 6226f85caf Fix broken subscription status tag on subscriber form UI. 2024-09-16 23:00:59 +05:30
Kailash Nadh a94d7cc8c4 Add OIDC auth hooks (init, callback, session) and finish OIDC support. 2024-09-16 23:00:59 +05:30
Kailash Nadh 2fe1b808d0 Add avatar field to user schema for OIDC avatars. 2024-09-16 23:00:59 +05:30
Kailash Nadh 96f85308c1 Update OIDC auth URL in login form. 2024-09-16 23:00:59 +05:30
Kailash Nadh 1437fe8f8a Apply OIDC/user profile related changes to admin UI. 2024-09-16 23:00:59 +05:30
Kailash Nadh 94446ca744 Add one-click provider config shortcut in OIDC settings. 2024-09-16 23:00:59 +05:30
Kailash Nadh 874e12ed10 Refactor update check.
- Switch away from GitHub releases API to a statically hosted custom
  JSON message to include richer data.
- Instead of checking 24 hours post-boot, check 15 mins later post boot
  and then every 24 hours.
- Add provision for messages to display on the admin dashboard to
  communicate important / urgent announcements.
  (Fingers crossed, this never has to be used!)
2024-09-16 23:00:59 +05:30
Kailash Nadh 01c64de7a8 Add warning on admin UI for legacy creds in the TOML file. 2024-09-16 23:00:59 +05:30
Kailash Nadh fbc5807f4a Apply minor linting fixes to role form. 2024-09-16 23:00:59 +05:30
Kailash Nadh 64e56b58d5 Add cookie check hack to auth for v3 -> 4 browser BasicAuth session issue. 2024-09-16 23:00:59 +05:30
Kailash Nadh 46fbac3c00 Sort roles by created date. 2024-09-16 23:00:59 +05:30
Kailash Nadh d1184de18d Update user APIs and queries to embed role + list permissions. 2024-09-16 23:00:58 +05:30
Kailash Nadh f632dfbce1 Add per-list permission management to roles. 2024-09-16 23:00:58 +05:30
Kailash Nadh 728877dbe6 Add new fields to /api/config to remove /settings dependency in camapign UI. 2024-09-16 23:00:58 +05:30
Kailash Nadh fc95985ef4 Move User/Roles nav items under Settings. 2024-09-16 23:00:58 +05:30
Kailash Nadh 5ea931a2ff Minor refactor to subscribers UI. Remove superfluous status column. 2024-09-16 23:00:58 +05:30
Kailash Nadh bca487cbee Add permission checks to admin UI to toggle visibility/functionality of components. 2024-09-16 23:00:58 +05:30
Kailash Nadh e865847b66 Add user profile based permission check in auth middleware. 2024-09-16 23:00:58 +05:30
Kailash Nadh 6d1dabd0bf Fix profile edit page. 2024-09-16 23:00:58 +05:30
Kailash Nadh 217590ea0e Refactor 'super' user type to a pre-defined super admin role. 2024-09-16 23:00:58 +05:30
Kailash Nadh 5c5fd8a15d Restyle tags on the UI. 2024-09-16 23:00:58 +05:30
Kailash Nadh f57ac201ff Add granular permissions and role management to backend and admin UI. 2024-09-16 23:00:58 +05:30
Kailash Nadh 2bb4e19b74 Style and add OIDC logo to the login page. 2024-09-16 23:00:58 +05:30
Kailash Nadh 13ac249afb Upgrade simplesessions to v3. 2024-09-16 23:00:58 +05:30
Kailash Nadh 5e5d012312 Make user avatar field nullable. 2024-09-16 23:00:58 +05:30
Kailash Nadh 938a5c5077 Add user profile APIs and update UI. 2024-09-16 23:00:58 +05:30
Kailash Nadh 7c3ee469bd Update login credentials doc in sample config. 2024-09-16 23:00:58 +05:30
Kailash Nadh a87167ac9c Refactor migration for the latest version. 2024-09-16 23:00:58 +05:30
Kailash Nadh 31c5358d0e Refactor handler groups and add mising auth features like logout. 2024-09-16 23:00:58 +05:30
Kailash Nadh a4e8c1daea Add public login page and auth middleware and handlers. 2024-09-16 23:00:58 +05:30
Kailash Nadh c2bd15a641 Add api type user. 2024-09-16 23:00:58 +05:30
Kailash Nadh 8b2f385708 Add API token authentication. 2024-09-16 23:00:58 +05:30
Kailash Nadh 2fcecd6db5 Add missing user UI files. 2024-09-16 23:00:58 +05:30
Kailash Nadh 5832ea5384 Add user/password login handler. 2024-09-16 23:00:58 +05:30
Kailash Nadh 98213ebf24 Add create/add/delete user management UI and database schema. 2024-09-16 23:00:58 +05:30
Kailash Nadh 4679f98067 Fix bug in OIDC cookie check. 2024-09-16 23:00:58 +05:30
Kailash Nadh b832f8e82e Add migrations for OIDC db fields. 2024-09-16 23:00:58 +05:30
Kailash Nadh e6f59da886 Refactor the oidc package and separate out handlers. 2024-09-16 23:00:58 +05:30
Kailash Nadh 06264ca13f Refactor OIDC middleware handler logic. 2024-09-16 23:00:57 +05:30
Kailash Nadh 011d89144d Add a settings UI for OIDC. 2024-09-16 23:00:57 +05:30
Marc Bärtschi 67a33b40eb Implement OIDC
This is a simple OIDC implementation. It's very basic and just logs the user in. Access control needs to be done on the IDP side.
2024-09-16 23:00:57 +05:30
Bishop Clark 550cd3e1f8
Update README.md (#2034)
'software', when used as a noun, is not a 'countable' type, and it does not get an article like 'a'.  It's like 'traffic'.
2024-09-06 15:49:53 +05:30
Ken Powers 06e49831dd
Fix tag queyr param in lists.md (#2033) 2024-09-05 09:06:13 +05:30
Kailash Nadh 51e3f1789b Fix pre-confirm status not working on subscriber update. Closes #1927. 2024-09-03 23:39:02 +05:30
Kailash Nadh 139267d57e Tweak docs to highlight one-way mailing lists. Closes #1931. 2024-09-03 22:46:55 +05:30
76 changed files with 4677 additions and 557 deletions

View file

@ -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.

View file

@ -48,7 +48,7 @@ __________________
## Developers
listmonk is a free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.
listmonk is free and open source software licensed under AGPLv3. If you are interested in contributing, refer to the [developer setup](https://listmonk.app/docs/developer-setup). The backend is written in Go and the frontend is Vue with Buefy for UI.
## License

View file

@ -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

221
cmd/auth.go Normal file
View file

@ -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
}

View file

@ -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)
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)
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)
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)
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)
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"))
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/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))
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"))
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)
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"))
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)
// 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))
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)
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"))
g.GET("/api/media", handleGetMedia)
g.GET("/api/media/:id", handleGetMedia)
g.POST("/api/media", handleUploadMedia)
g.DELETE("/api/media/:id", handleDeleteMedia)
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"))
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)
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"))
g.DELETE("/api/maintenance/subscribers/:type", handleGCSubscribers)
g.DELETE("/api/maintenance/analytics/:type", handleGCCampaignAnalytics)
g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions)
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"))
g.POST("/api/tx", handleSendTxMessage)
api.POST("/api/tx", pm(handleSendTxMessage, "tx:send"))
g.GET("/api/events", handleEventStream)
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 {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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"))
}
}

View file

@ -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)

View file

@ -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")))

216
cmd/roles.go Normal file
View file

@ -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
}

View file

@ -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), "."))

View file

@ -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"
@ -67,12 +68,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
@ -84,8 +90,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"))
@ -95,10 +102,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)
@ -118,16 +125,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?
@ -186,7 +194,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
)
@ -201,8 +211,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
}
@ -213,7 +226,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
@ -241,7 +256,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
}
@ -317,7 +335,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
)
@ -346,15 +366,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"))
}
@ -445,7 +468,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
)
@ -457,15 +482,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)
err = app.core.AddSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs, req.Status)
case "remove":
err = app.core.DeleteSubscriptionsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
err = app.core.DeleteSubscriptionsByQuery(req.Query, sourceListIDs, targetListIDs)
case "unsubscribe":
err = app.core.UnsubscribeListsByQuery(req.Query, req.ListIDs, req.TargetListIDs)
err = app.core.UnsubscribeListsByQuery(req.Query, sourceListIDs, targetListIDs)
default:
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
}
@ -631,3 +660,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
}

View file

@ -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()
}
}

View file

@ -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

288
cmd/users.go Normal file
View file

@ -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
}

View file

@ -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"

View file

@ -15,7 +15,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:

View file

@ -21,7 +21,7 @@ Retrieve lists.
|:---------|:---------|:---------|:-----------------------------------------------------------------|
| query | string | | string for list name search. |
| status | []string | | Status to filter lists. Repeat in the query for multiple values. |
| tags | []string | | Tags to filter lists. Repeat in the query for multiple values. |
| tag | []string | | Tags to filter lists. Repeat in the query for multiple values. |
| order_by | string | | Sort field. Options: name, status, created_at, updated_at. |
| order | string | | Sorting order. Options: ASC, DESC. |
| page | number | | Page number for pagination. |

View file

@ -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

View file

@ -2,7 +2,7 @@
[![listmonk](images/logo.svg)](https://listmonk.app)
listmonk is a self-hosted, high performance mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database.
listmonk is a self-hosted, high performance one-way mailing list and newsletter manager. It comes as a standalone binary and the only dependency is a Postgres database.
[![listmonk screenshot](https://user-images.githubusercontent.com/547147/134939475-e0391111-f762-44cb-b056-6cb0857755e3.png)](https://listmonk.app)

View file

@ -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/)

View file

@ -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.

View file

@ -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

View file

@ -95,7 +95,7 @@ sh -c "$(curl -fsSL https://raw.githubusercontent.com/knadh/listmonk/master/inst
<img class="box" src="static/images/lists.png" alt="Screenshot of list management feature" />
</div>
<p>
Manage millions of subscribers across many single and double opt-in lists
Manage millions of subscribers across many single and double opt-in one-way mailing lists
with custom JSON attributes for each subscriber.
Query and segment subscribers with SQL expressions.
</p>

View file

@ -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,

View file

@ -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",

View file

@ -12,9 +12,29 @@
<template #end>
<navigation v-if="isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-item v-else tag="div">
<a href="#" @click.prevent="doLogout">{{ $t('users.logout') }}</a>
</b-navbar-item>
<b-navbar-dropdown class="user" tag="div" right v-else>
<template v-if="profile.username" #label>
<span class="user-avatar">
<img v-if="profile.avatar" :src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
</span>
</template>
<b-navbar-item class="user-name" tag="router-link" to="/user/profile">
<strong>{{ profile.username }}</strong>
<div class="is-size-7">{{ profile.name }}</div>
</b-navbar-item>
<b-navbar-item href="#">
<router-link to="/user/profile">
<b-icon icon="account-outline" /> {{ $t('users.profile') }}
</router-link>
</b-navbar-item>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="logout-variant" /> {{ $t('users.logout') }}</a>
</b-navbar-item>
</b-navbar-dropdown>
</template>
</b-navbar>
@ -33,7 +53,8 @@
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needs_restart || serverConfig.update">
<div class="global-notices"
v-if="serverConfig.needs_restart || serverConfig.update || serverConfig.has_legacy_user">
<div v-if="serverConfig.needs_restart" class="notification is-danger">
{{ $t('settings.needsRestart') }}
&mdash;
@ -42,9 +63,35 @@
{{ $t('settings.restart') }}
</b-button>
</div>
<div v-if="serverConfig.update" class="notification is-success">
{{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }}
<a :href="serverConfig.update.url" target="_blank" rel="noopener noreferer">View</a>
<template v-if="serverConfig.update">
<div v-if="serverConfig.update.update.is_new" class="notification is-success">
{{ $t('settings.updateAvailable', {
version: `${serverConfig.update.update.release_version}
(${$utils.getDate(serverConfig.update.update.release_date).format('DD MMM YY')})`,
}) }}
<a :href="serverConfig.update.update.url" target="_blank" rel="noopener noreferer">View</a>
</div>
<template v-if="serverConfig.update.messages && serverConfig.update.messages.length > 0">
<div v-for="m in serverConfig.update.messages" class="notification"
:class="{ [m.priority === 'high' ? 'is-danger' : 'is-info']: true }" :key="m.title">
<h3 class="is-size-5" v-if="m.title"><strong>{{ m.title }}</strong></h3>
<p v-if="m.description">{{ m.description }}</p>
<a v-if="m.url" :href="m.url" target="_blank" rel="noopener noreferer">View</a>
</div>
</template>
</template>
<div v-if="serverConfig.has_legacy_user" class="notification is-danger">
<b-icon icon="warning-empty" />
Remove the <code>admin_username</code> and <code>admin_password</code> 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
<router-link :to="{ name: 'users' }">
Admin -> Settings -> Users
</router-link> dashboard. <a href="https://listmonk.app/docs/upgrade/#upgrading-to-v4xx" target="_blank"
rel="noopener noreferer">Learn more.</a>
</div>
</div>
@ -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;

View file

@ -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 },
);

View file

@ -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' */

View file

@ -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 {

View file

@ -4,7 +4,10 @@
<b-taglist>
<b-tag v-for="l in selectedItems" :key="l.id" :class="l.subscriptionStatus" :closable="!$props.disabled"
:data-id="l.id" @close="removeList(l.id)" class="list">
{{ l.name }} <sup v-if="l.optin === 'double'">{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}</sup>
{{ l.name }}
<sup v-if="l.optin === 'double' && l.subscriptionStatus">
{{ $t(`subscribers.status.${l.subscriptionStatus}`) }}
</sup>
</b-tag>
</b-taglist>
</div>

View file

@ -12,40 +12,56 @@
icon="newspaper-variant-outline" :label="$t('menu.forms')" />
</b-menu-item><!-- lists -->
<b-menu-item :expanded="activeGroup.subscribers" :active="activeGroup.subscribers" data-cy="subscribers"
@update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
<b-menu-item v-if="$can('subscribers:*')" :expanded="activeGroup.subscribers" :active="activeGroup.subscribers"
data-cy="subscribers" @update:active="(state) => toggleGroup('subscribers', state)" icon="account-multiple"
:label="$t('globals.terms.subscribers')">
<b-menu-item :to="{ name: 'subscribers' }" tag="router-link" :active="activeItem.subscribers"
data-cy="all-subscribers" icon="account-multiple" :label="$t('menu.allSubscribers')" />
<b-menu-item :to="{ name: 'import' }" tag="router-link" :active="activeItem.import" data-cy="import"
icon="file-upload-outline" :label="$t('menu.import')" />
<b-menu-item :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces" data-cy="bounces"
icon="email-bounce" :label="$t('globals.terms.bounces')" />
<b-menu-item v-if="$can('subscribers:get_all', 'subscribers:get')" :to="{ name: 'subscribers' }" tag="router-link"
:active="activeItem.subscribers" data-cy="all-subscribers" icon="account-multiple"
:label="$t('menu.allSubscribers')" />
<b-menu-item v-if="$can('subscribers:import')" :to="{ name: 'import' }" tag="router-link"
:active="activeItem.import" data-cy="import" icon="file-upload-outline" :label="$t('menu.import')" />
<b-menu-item v-if="$can('bounces:get')" :to="{ name: 'bounces' }" tag="router-link" :active="activeItem.bounces"
data-cy="bounces" icon="email-bounce" :label="$t('globals.terms.bounces')" />
</b-menu-item><!-- subscribers -->
<b-menu-item :expanded="activeGroup.campaigns" :active="activeGroup.campaigns" data-cy="campaigns"
@update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
<b-menu-item v-if="$can('campaigns:*')" :expanded="activeGroup.campaigns" :active="activeGroup.campaigns"
data-cy="campaigns" @update:active="(state) => toggleGroup('campaigns', state)" icon="rocket-launch-outline"
:label="$t('globals.terms.campaigns')">
<b-menu-item :to="{ name: 'campaigns' }" tag="router-link" :active="activeItem.campaigns" data-cy="all-campaigns"
icon="rocket-launch-outline" :label="$t('menu.allCampaigns')" />
<b-menu-item :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" :active="activeItem.campaign"
data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
<b-menu-item :to="{ name: 'media' }" tag="router-link" :active="activeItem.media" data-cy="media"
icon="image-outline" :label="$t('menu.media')" />
<b-menu-item :to="{ name: 'templates' }" tag="router-link" :active="activeItem.templates" data-cy="templates"
icon="file-image-outline" :label="$t('globals.terms.templates')" />
<b-menu-item :to="{ name: 'campaignAnalytics' }" tag="router-link" :active="activeItem.campaignAnalytics"
data-cy="analytics" icon="chart-bar" :label="$t('globals.terms.analytics')" />
<b-menu-item v-if="$can('campaigns:get')" :to="{ name: 'campaigns' }" tag="router-link"
:active="activeItem.campaigns" data-cy="all-campaigns" icon="rocket-launch-outline"
:label="$t('menu.allCampaigns')" />
<b-menu-item v-if="$can('campaigns:manage')" :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link"
:active="activeItem.campaign" data-cy="new-campaign" icon="plus" :label="$t('menu.newCampaign')" />
<b-menu-item v-if="$can('media:*')" :to="{ name: 'media' }" tag="router-link" :active="activeItem.media"
data-cy="media" icon="image-outline" :label="$t('menu.media')" />
<b-menu-item v-if="$can('templates:get')" :to="{ name: 'templates' }" tag="router-link"
:active="activeItem.templates" data-cy="templates" icon="file-image-outline"
:label="$t('globals.terms.templates')" />
<b-menu-item v-if="$can('campaigns:get_analytics')" :to="{ name: 'campaignAnalytics' }" tag="router-link"
:active="activeItem.campaignAnalytics" data-cy="analytics" icon="chart-bar"
:label="$t('globals.terms.analytics')" />
</b-menu-item><!-- campaigns -->
<b-menu-item :expanded="activeGroup.settings" :active="activeGroup.settings" data-cy="settings"
@update:active="(state) => toggleGroup('settings', state)" icon="cog-outline" :label="$t('menu.settings')">
<b-menu-item :to="{ name: 'settings' }" tag="router-link" :active="activeItem.settings" data-cy="all-settings"
icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item :to="{ name: 'maintenance' }" tag="router-link" :active="activeItem.maintenance" data-cy="maintenance"
icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs" data-cy="logs"
icon="newspaper-variant-outline" :label="$t('menu.logs')" />
<b-menu-item v-if="$can('users:*', 'roles:*')" :expanded="activeGroup.users" :active="activeGroup.users"
data-cy="users" @update:active="(state) => toggleGroup('users', state)" icon="account-multiple"
:label="$t('globals.terms.users')">
<b-menu-item v-if="$can('users:get')" :to="{ name: 'users' }" tag="router-link" :active="activeItem.users"
data-cy="users" icon="account-multiple" :label="$t('globals.terms.users')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'userRoles' }" tag="router-link" :active="activeItem.userRoles"
data-cy="userRoles" icon="newspaper-variant-outline" :label="$t('users.userRoles')" />
<b-menu-item v-if="$can('roles:get')" :to="{ name: 'listRoles' }" tag="router-link" :active="activeItem.listRoles"
data-cy="listRoles" icon="format-list-bulleted-square" :label="$t('users.listRoles')" />
</b-menu-item><!-- users -->
<b-menu-item v-if="$can('settings:*')" :expanded="activeGroup.settings" :active="activeGroup.settings"
data-cy="settings" @update:active="(state) => toggleGroup('settings', state)" icon="cog-outline"
:label="$t('menu.settings')">
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'settings' }" tag="router-link"
:active="activeItem.settings" data-cy="all-settings" icon="cog-outline" :label="$t('menu.settings')" />
<b-menu-item v-if="$can('settings:maintain')" :to="{ name: 'maintenance' }" tag="router-link"
:active="activeItem.maintenance" data-cy="maintenance" icon="wrench-outline" :label="$t('menu.maintenance')" />
<b-menu-item v-if="$can('settings:get')" :to="{ name: 'logs' }" tag="router-link" :active="activeItem.logs"
data-cy="logs" icon="format-list-bulleted-square" :label="$t('menu.logs')" />
</b-menu-item><!-- settings -->
<b-menu-item v-if="isMobile" icon="logout-variant" :label="$t('users.logout')" @click.prevent="doLogout" />
@ -53,6 +69,8 @@
</template>
<script>
import { mapState } from 'vuex';
export default {
name: 'Navigation',
@ -72,6 +90,10 @@ export default {
},
},
computed: {
...mapState(['profile']),
},
mounted() {
// A hack to close the open accordion burger menu items on click.
// Buefy does not have a way to do this.

View file

@ -8,6 +8,10 @@ export const models = Object.freeze({
templates: 'templates',
media: 'media',
bounces: 'bounces',
users: 'users',
profile: 'profile',
userRoles: 'userRoles',
listRoles: 'listRoles',
settings: 'settings',
logs: 'logs',
maintenance: 'maintenance',

View file

@ -31,28 +31,45 @@ router.afterEach((to) => {
});
});
function initConfig(app) {
// Load server side config and language before mounting the app.
api.getServerConfig().then((data) => {
api.getLang(data.lang).then((lang) => {
i18n.locale = data.lang;
i18n.setLocaleMessage(i18n.locale, lang);
async function initConfig(app) {
// Load logged in user profile, server side config, and the language file before mounting the app.
const [profile, cfg] = await Promise.all([api.getUserProfile(), api.getServerConfig()]);
Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;
const lang = await api.getLang(cfg.lang);
i18n.locale = cfg.lang;
i18n.setLocaleMessage(i18n.locale, lang);
// Set the page title after i18n has loaded.
const to = router.history.current;
const t = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${t} listmonk`;
Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;
if (app) {
app.$mount('#app');
// $can('permission:name') is used in the UI to chekc whether the logged in user
// has a certain permission to toggle visibility of UI objects and UI functionality.
Vue.prototype.$can = (...perms) => {
if (profile.userRole.id === 1) {
return true;
}
// If the perm ends with a wildcard, check whether at least one permission
// in the group is present. Eg: campaigns:* will return true if at least
// one of campaigns:get, campaigns:manage etc. are present.
return perms.some((perm) => {
if (perm.endsWith('*')) {
const group = `${perm.split(':')[0]}:`;
return profile.userRole.permissions.some((p) => p.startsWith(group));
}
});
});
api.getSettings();
return profile.userRole.permissions.includes(perm);
});
};
// Set the page title after i18n has loaded.
const to = router.history.current;
const title = to.meta.title ? `${i18n.tc(to.meta.title, 0)} /` : '';
document.title = `${title} listmonk`;
if (app) {
app.$mount('#app');
}
}
const v = new Vue({

View file

@ -95,6 +95,12 @@ const routes = [
meta: { title: 'globals.terms.campaign', group: 'campaigns' },
component: () => import('../views/Campaign.vue'),
},
{
path: '/user/profile',
name: 'userProfile',
meta: { title: 'users.profile', group: 'settings' },
component: () => import('../views/UserProfile.vue'),
},
{
path: '/settings',
name: 'settings',
@ -107,6 +113,24 @@ const routes = [
meta: { title: 'logs.title', group: 'settings' },
component: () => import('../views/Logs.vue'),
},
{
path: '/users',
name: 'users',
meta: { title: 'globals.terms.users', group: 'users' },
component: () => import('../views/Users.vue'),
},
{
path: '/users/roles/users',
name: 'userRoles',
meta: { title: 'users.userRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
path: '/users/roles/lists',
name: 'listRoles',
meta: { title: 'users.listRoles', group: 'users' },
component: () => import('../views/Roles.vue'),
},
{
path: '/settings/maintenance',
name: 'maintenance',

View file

@ -41,6 +41,10 @@ export default new Vuex.Store({
[models.campaigns]: (state) => state[models.campaigns],
[models.media]: (state) => state[models.media],
[models.templates]: (state) => state[models.templates],
[models.users]: (state) => state[models.users],
[models.profile]: (state) => state[models.profile],
[models.userRoles]: (state) => state[models.userRoles],
[models.listRoles]: (state) => state[models.listRoles],
[models.settings]: (state) => state[models.settings],
[models.serverConfig]: (state) => state[models.serverConfig],
[models.logs]: (state) => state[models.logs],

View file

@ -23,7 +23,7 @@
</div>
<div class="column is-6">
<div class="buttons">
<div v-if="$can('campaigns:manage')" class="buttons">
<b-field grouped v-if="isEditing && canEdit">
<b-field expanded>
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
@ -113,7 +113,8 @@
:message="form.sendAtDate ? $utils.duration(Date(), form.sendAtDate) : ''">
<b-datetimepicker v-model="form.sendAtDate" :disabled="!canEdit"
:placeholder="$t('campaigns.dateAndTime')" icon="calendar-clock"
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime" horizontal-time-picker />
:timepicker="{ hourFormat: '24' }" :datetime-formatter="formatDateTime"
horizontal-time-picker />
</b-field>
</div>
</div>
@ -140,7 +141,7 @@
</b-field>
</form>
</div>
<div class="column is-4 is-offset-1">
<div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
<br />
<div class="box">
<h3 class="title is-size-6">
@ -175,14 +176,15 @@
</a>
</p>
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border" expanded
data-cy="media">
<b-field v-if="isAttachFieldVisible" :label="$t('campaigns.attachments')" label-position="on-border"
expanded data-cy="media">
<b-taginput v-model="form.media" name="media" ellipsis icon="tag-outline" ref="media" field="filename"
@focus="onOpenAttach" :disabled="!canEdit" />
</b-field>
</div>
<div class="column has-text-right">
<a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank" rel="noopener noreferer">
<a href="https://listmonk.app/docs/templating/#template-expressions" target="_blank"
rel="noopener noreferer">
<b-icon icon="code" /> {{ $t('campaigns.templatingRef') }}</a>
<span v-if="canEdit && form.content.contentType !== 'plain'" class="is-size-6 has-text-grey ml-6">
<a v-if="form.altbody === null" href="#" @click.prevent="onAddAltBody">
@ -212,7 +214,7 @@
<b-switch data-cy="btn-archive" v-model="form.archive" :disabled="!canArchive" />
</div>
<div class="column is-12">
<a :href="`${settings['app.root_url']}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
<a :href="`${serverConfig.root_url}/archive/${data.uuid}`" target="_blank" rel="noopener noreferer"
:class="{ 'has-text-grey-light': !form.archive }" aria-label="$t('campaigns.archive')">
<b-icon icon="link-variant" />
</a>
@ -245,8 +247,8 @@
</div>
<div class="column has-text-right">
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary" href="#"
@click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
<a v-if="!this.form.archiveMetaStr || this.form.archiveMetaStr === '{}'" class="button is-primary"
href="#" @click.prevent="onFillArchiveMeta" aria-label="{}"><b-icon icon="code" /></a>
</div>
</div>
<b-field>
@ -596,7 +598,7 @@ export default Vue.extend({
},
computed: {
...mapState(['settings', 'loading', 'lists', 'templates']),
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
canEdit() {
return this.isNew
@ -624,7 +626,7 @@ export default Vue.extend({
},
messengers() {
return ['email', ...this.settings.messengers.map((m) => m.name)];
return ['email', ...this.serverConfig.messengers.map((m) => m.name)];
},
},
@ -646,7 +648,7 @@ export default Vue.extend({
window.onbeforeunload = () => this.isUnsaved() || null;
// Fill default form fields.
this.form.fromEmail = this.settings['app.from_email'];
this.form.fromEmail = this.serverConfig.from_email;
// New campaign.
const { id } = this.$route.params;

View file

@ -8,7 +8,7 @@
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-field v-if="$can('campaigns:manage')" expanded>
<b-button expanded :to="{ name: 'campaign', params: { id: 'new' } }" tag="router-link" class="btn-new"
type="is-primary" icon-left="plus" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
@ -170,51 +170,56 @@
<b-table-column v-slot="props" cell-class="actions" width="15%" align="right">
<div>
<!-- start / pause / resume / scheduled -->
<a v-if="canStart(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-start"
:aria-label="$t('campaigns.start')">
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canPause(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
:aria-label="$t('campaigns.pause')">
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canResume(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))" data-cy="btn-resume"
:aria-label="$t('campaigns.send')">
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canSchedule(props.row)" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<template v-if="$can('campaigns:manage')">
<a v-if="canStart(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
data-cy="btn-start" :aria-label="$t('campaigns.start')">
<b-tooltip :label="$t('campaigns.start')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<!-- placeholder for finished campaigns -->
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
aria-label=" ">
<b-icon icon="rocket-launch-outline" size="is-small" />
</a>
<a v-if="canPause(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'paused'))" data-cy="btn-pause"
:aria-label="$t('campaigns.pause')">
<b-tooltip :label="$t('campaigns.pause')" type="is-dark">
<b-icon icon="pause-circle-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canCancel(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<a v-if="canResume(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'running'))"
data-cy="btn-resume" :aria-label="$t('campaigns.send')">
<b-tooltip :label="$t('campaigns.send')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="canSchedule(props.row)" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmSchedule'), () => changeCampaignStatus(props.row, 'scheduled'))"
data-cy="btn-schedule" :aria-label="$t('campaigns.schedule')">
<b-tooltip :label="$t('campaigns.schedule')" type="is-dark">
<b-icon icon="clock-start" size="is-small" />
</b-tooltip>
</a>
<!-- placeholder for finished campaigns -->
<a v-if="!canCancel(props.row) && !canSchedule(props.row) && !canStart(props.row)" href="#" data-disabled
aria-label=" ">
<b-icon icon="rocket-launch-outline" size="is-small" />
</a>
<a v-if="canCancel(props.row)" href="#"
@click.prevent="$utils.confirm(null, () => changeCampaignStatus(props.row, 'cancelled'))"
data-cy="btn-cancel" :aria-label="$t('globals.buttons.cancel')">
<b-tooltip :label="$t('globals.buttons.cancel')" type="is-dark">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a v-else href="#" data-disabled aria-label=" ">
<b-icon icon="cancel" size="is-small" />
</b-tooltip>
</a>
<a v-else href="#" data-disabled aria-label=" ">
<b-icon icon="cancel" size="is-small" />
</a>
</a>
</template>
<a href="#" @click.prevent="previewCampaign(props.row)" data-cy="btn-preview"
:aria-label="$t('campaigns.preview')">
@ -222,7 +227,7 @@
<b-icon icon="file-find-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
<a v-if="$can('campaigns:manage')" href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }),
@ -232,12 +237,13 @@
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<router-link :to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
<router-link v-if="$can('campaigns:get_analytics')"
:to="{ name: 'campaignAnalytics', query: { id: props.row.id } }">
<b-tooltip :label="$t('globals.terms.analytics')" type="is-dark">
<b-icon icon="chart-bar" size="is-small" />
</b-tooltip>
</router-link>
<a href="#"
<a v-if="$can('campaigns:manage')" href="#"
@click.prevent="$utils.confirm($t('campaigns.confirmDelete', { name: props.row.name }), () => deleteCampaign(props.row))"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-icon icon="trash-can-outline" size="is-small" />

View file

@ -58,7 +58,7 @@
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
<b-button v-if="canManage" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
@ -123,7 +123,16 @@ export default Vue.extend({
},
computed: {
...mapState(['loading']),
...mapState(['loading', 'profile']),
canManage() {
if (this.$can('lists:manage_all')) {
return true;
}
const list = this.profile.userRole.lists.find((l) => l.id === this.$props.data.id);
return list && list.permissions.includes('list:manage');
},
},
mounted() {

View file

@ -8,7 +8,7 @@
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-field v-if="$can('lists:manage_all')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
@ -25,7 +25,8 @@
<form @submit.prevent="getLists">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query" data-cy="query" />
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
@ -78,10 +79,15 @@
<b-table-column v-slot="props" field="subscriber_count" :label="$t('globals.terms.subscribers')"
header-class="cy-subscribers" numeric sortable centered>
<router-link :to="`/subscribers/lists/${props.row.id}`">
<template v-if="$can('subscribers:get_all', 'subscribers:get')">
<router-link :to="`/subscribers/lists/${props.row.id}`">
{{ $utils.formatNumber(props.row.subscriberCount) }}
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
</router-link>
</template>
<template v-else>
{{ $utils.formatNumber(props.row.subscriberCount) }}
<span class="is-size-7 view">{{ $t('globals.buttons.view') }}</span>
</router-link>
</template>
</b-table-column>
<b-table-column v-slot="props" field="subscriber_counts" header-class="cy-subscribers" width="10%">
@ -106,26 +112,28 @@
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<router-link :to="`/campaigns/new?list_id=${props.row.id}`" data-cy="btn-campaign">
<router-link v-if="$can('campaigns:manage')" :to="`/campaigns/new?list_id=${props.row.id}`"
data-cy="btn-campaign">
<b-tooltip :label="$t('lists.sendCampaign')" type="is-dark">
<b-icon icon="rocket-launch-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
<a v-if="$can('lists:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<router-link :to="{ name: 'import', query: { list_id: props.row.id } }" data-cy="btn-import">
<router-link v-if="$can('lists:import')" :to="{ name: 'import', query: { list_id: props.row.id } }"
data-cy="btn-import">
<b-tooltip :label="$t('import.title')" type="is-dark">
<b-icon icon="file-upload-outline" size="is-small" />
</b-tooltip>
</router-link>
<a href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
<a v-if="$can('lists:manage')" href="#" @click.prevent="deleteList(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />

View file

@ -0,0 +1,280 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
</p>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ type === 'user' ? $t('users.newUserRole') : $t('users.newListRole') }}
</h4>
</header>
<section expanded class="modal-card-body">
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input autofocus :disabled="disabled" :maxlength="200" v-model="form.name" name="name" ref="focus"
required />
</b-field>
<div v-if="type === 'list'" class="box">
<h5>{{ $t('users.listPerms') }}</h5>
<div class="mb-5">
<div class="columns">
<div class="column is-9">
<b-select :placeholder="$tc('globals.terms.list')" v-model="form.curList" name="list"
:disabled="disabled || filteredLists.length < 1" expanded class="mb-3">
<template v-for="l in filteredLists">
<option :value="l.id" :key="l.id">
{{ l.name }}
</option>
</template>
</b-select>
</div>
<div class="column">
<b-button @click="onAddListPerm" :disabled="!form.curList" class="is-primary" expanded>
{{ $t('globals.buttons.add') }}
</b-button>
</div>
</div>
<span
v-if="form.lists.length > 0 && (form.permissions['lists:get_all'] || form.permissions['lists:manage_all'])"
class="is-size-6 has-text-danger">
<b-icon icon="warning-empty" />
{{ $t('users.listPermsWarning') }}
</span>
</div>
<b-table :data="form.lists">
<b-table-column v-slot="props" field="name" :label="$tc('globals.terms.list')">
<router-link :to="`/lists/${props.row.id}`" target="_blank">
{{ props.row.name }}
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="permissions" :label="$t('users.perms')" width="40%">
<b-checkbox v-model="props.row.permissions" native-value="list:get">
{{ $t('globals.buttons.view') }}
</b-checkbox>
<b-checkbox v-model="props.row.permissions" native-value="list:manage">
{{ $t('globals.buttons.manage') }}
</b-checkbox>
</b-table-column>
<b-table-column v-slot="props" width="10%">
<a href="#" @click.prevent="onDeleteListPerm(props.row.id)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</b-table-column>
</b-table>
</div>
<template v-if="type === 'user'">
<div class="columns">
<div class="column is-7">
<h5 class="mb-0">
{{ $t('users.perms') }}
</h5>
</div>
<div class="column has-text-right" v-if="!disabled">
<a href="#" @click.prevent="onToggleSelect">{{ $t('globals.buttons.toggleSelect') }}</a>
</div>
</div>
<b-table :data="serverConfig.permissions">
<b-table-column v-slot="props" field="group" :label="$t('users.roleGroup')">
{{ $tc(`globals.terms.${props.row.group}`) }}
</b-table-column>
<b-table-column v-slot="props" field="permissions" label="Permissions">
<div v-for="p in props.row.permissions" :key="p">
<b-checkbox v-model="form.permissions" :native-value="p" :disabled="disabled">
{{ p }}
</b-checkbox>
</div>
</b-table-column>
</b-table>
</template>
<a href="https://listmonk.app/docs/roles-and-permissions" target="_blank" rel="noopener noreferrer">
<b-icon icon="link-variant" /> {{ $t('globals.buttons.learnMore') }}
</a>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="!disabled" native-type="submit" type="is-primary" :loading="loading.roles" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'RoleForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
type: { type: String, default: 'user' },
},
data() {
return {
// Binds form input values.
form: {
curList: null,
lists: [],
name: null,
permissions: {},
},
hasToggle: false,
disabled: false,
};
},
methods: {
onAddListPerm() {
const list = this.lists.results.find((l) => l.id === this.form.curList);
this.form.lists.push({ id: list.id, name: list.name, permissions: ['list:get', 'list:manage'] });
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onDeleteListPerm(id) {
this.form.lists = this.form.lists.filter((p) => p.id !== id);
this.form.curList = (this.filteredLists.length > 0) ? this.filteredLists[0].id : null;
},
onSubmit() {
if (this.isEditing) {
this.updateRole();
return;
}
this.createRole();
},
onToggleSelect() {
if (this.hasToggle) {
this.form.permissions = [];
} else {
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
item.permissions.forEach((p) => {
acc.push(p);
});
return acc;
}, []);
}
this.hasToggle = !this.hasToggle;
},
createRole() {
let fn;
const form = { name: this.form.name };
if (this.$props.type === 'user') {
fn = this.$api.createUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.createListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
this.$parent.close();
});
},
updateRole() {
let fn;
const form = { id: this.$props.data.id, name: this.form.name };
if (this.$props.type === 'user') {
fn = this.$api.updateUserRole;
form.permissions = this.form.permissions;
} else {
fn = this.$api.updateListRole;
form.lists = this.form.lists.reduce((acc, item) => {
acc.push({ id: item.id, permissions: item.permissions });
return acc;
}, []);
}
fn(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
this.$parent.close();
});
},
},
computed: {
...mapState(['loading', 'serverConfig', 'lists']),
// Return the list of unselected lists.
filteredLists() {
if (!this.lists.results || this.type !== 'list') {
return [];
}
const subIDs = this.form.lists.reduce((obj, item) => ({ ...obj, [item.id]: true }), {});
return this.lists.results.filter((l) => (!(l.id in subIDs)));
},
},
mounted() {
if (this.isEditing) {
this.form = { ...this.form, ...this.$props.data };
// It's the superadmin role. Disable the form.
if (this.$props.data.id === 1 || !this.$can('roles:manage')) {
this.disabled = true;
}
} else {
const skip = ['admin', 'users'];
this.form.permissions = this.serverConfig.permissions.reduce((acc, item) => {
if (skip.includes(item.group)) {
return acc;
}
item.permissions.forEach((p) => {
if (p !== 'subscribers:sql_query' && !p.startsWith('lists:') && !p.startsWith('settings:')) {
acc.push(p);
}
});
return acc;
}, []);
}
this.$nextTick(() => {
if (this.filteredLists.length > 0) {
this.form.curList = this.filteredLists[0].id;
}
this.$refs.focus.focus();
});
},
});
</script>

View file

@ -0,0 +1,193 @@
<template>
<section class="roles">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t(isUser ? 'users.userRoles' : 'users.listRoles') }}
<span v-if="!isNaN(roles.length)">({{ roles.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('users:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm('user')"
data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="roles" :loading="isLoading()" hoverable>
<b-table-column v-slot="props" field="role" :label="$tc('users.role')" sortable>
<a href="#" @click.prevent="showEditForm(props.row, 'user')">
<b-tag v-if="props.row.id === 1" class="enabled">
{{ props.row.name }}
</b-tag>
<template v-else>{{ props.row.name }}</template>
</a>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions has-text-right">
<template v-if="$can('roles:manage')">
<a href="#" @click.prevent="$utils.prompt($t('globals.buttons.clone'),
{
placeholder: $t('globals.fields.name'),
value: $t('campaigns.copyOf', { name: props.row.name }),
},
(name) => onCloneRole(name, props.row))" data-cy="btn-clone" :aria-label="$t('globals.buttons.clone')">
<b-tooltip :label="$t('globals.buttons.clone')" type="is-dark">
<b-icon icon="file-multiple-outline" size="is-small" />
</b-tooltip>
</a>
<template v-if="props.row.id !== 1">
<a href="#" @click.prevent="showEditForm(props.row, 'user')" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="onDeleteRole(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</template>
</template>
</b-table-column>
<template #empty v-if="!isLoading()">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="700" @close="onFormClose">
<role-form :data="curItem" :type="curType" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import RoleForm from './RoleForm.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
RoleForm,
},
data() {
return {
curItem: null,
curType: null,
isEditing: false,
isFormVisible: false,
};
},
methods: {
isLoading() {
return this.curType === 'user' ? this.loading.userRoles : this.loading.listRoles;
},
fetchRoles() {
if (this.isUser) {
this.$api.getUserRoles();
} else {
this.$api.getListRoles();
}
},
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.curType = this.isUser ? 'user' : 'list';
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.isEditing = false;
this.isFormVisible = true;
},
formFinished() {
this.fetchRoles();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'users' });
}
},
onCloneRole(name, item) {
const form = { name };
let fn;
if (this.isUser) {
fn = this.$api.createUserRole;
form.permissions = item.permissions;
} else {
fn = this.$api.createListRole;
form.lists = item.lists;
}
fn(form).then(() => {
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.created', { name }));
});
},
onDeleteRole(item) {
this.$utils.confirm(
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteRole(item.id).then(() => {
this.fetchRoles();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
},
);
},
},
computed: {
...mapState(['loading', 'userRoles', 'listRoles']),
isUser() {
return this.curType === 'user';
},
isList() {
return this.curType === 'list';
},
roles() {
return this.isUser ? this.userRoles : this.listRoles;
},
},
mounted() {
this.curType = this.$route.name === 'userRoles' ? 'user' : 'list';
this.fetchRoles();
},
});
</script>

View file

@ -10,7 +10,7 @@
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-field v-if="$can('settings:manage')" expanded>
<b-button expanded :disabled="!hasFormChanged" type="is-primary" icon-left="content-save-outline"
native-type="submit" class="isSaveEnabled" data-cy="btn-save">
{{ $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)) {

View file

@ -33,7 +33,8 @@
<div class="column is-4">
<b-field :label="$t('globals.fields.status')" label-position="on-border"
:message="$t('subscribers.blocklistedHelp')">
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required expanded>
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required
expanded>
<option value="enabled">
{{ $t('subscribers.status.enabled') }}
</option>
@ -55,7 +56,7 @@
</b-checkbox>
</b-field>
</div>
<div class="column is-5 has-text-right" v-if="isEditing">
<div v-if="$can('subscribers:manage') && isEditing" class="column is-5 has-text-right">
<a href="#" @click.prevent="sendOptinConfirmation" :class="{ 'is-disabled': !hasOptinList }">
<b-icon icon="email-outline" size="is-small" />
{{ $t('subscribers.sendOptinConfirm') }}</a>
@ -146,7 +147,8 @@
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button native-type="submit" type="is-primary" :loading="loading.subscribers">
<b-button v-if="$can('subscribers:manage')" native-type="submit" type="is-primary"
:loading="loading.subscribers">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>

View file

@ -13,7 +13,7 @@
</h1>
</div>
<div class="column has-text-right">
<b-field expanded>
<b-field v-if="$can('subscribers:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" @click="showNewForm" data-cy="btn-new" class="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
@ -42,7 +42,8 @@
data-cy="query" />
<span class="is-size-6 has-text-grey">
{{ $t('subscribers.advancedQueryHelp') }}.{{ ' ' }}
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank" rel="noopener noreferrer">
<a href="https://listmonk.app/docs/querying-and-segmentation" target="_blank"
rel="noopener noreferrer">
{{ $t('globals.buttons.learnMore') }}.
</a>
</span>
@ -69,8 +70,8 @@
</section><!-- control -->
<br />
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck" @check="onTableCheck"
:checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
<b-table :data="subscribers.results ?? []" :loading="loading.subscribers" @check-all="onTableCheck"
@check="onTableCheck" :checked-rows.sync="bulk.checked" paginated backend-pagination pagination-position="both"
@page-change="onPageChange" :current-page="queryParams.page" :per-page="subscribers.perPage"
:total="subscribers.total" hoverable checkable backend-sorting @sort="onSort">
<template #top-left>
@ -102,19 +103,14 @@
</div>
</template>
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
:td-attrs="$utils.tdID" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
<b-tag :class="props.row.status">
{{ $t(`subscribers.status.${props.row.status}`) }}
</b-tag>
</a>
</b-table-column>
<b-table-column v-slot="props" field="email" :label="$t('subscribers.email')" header-class="cy-email" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
{{ props.row.email }}
</a>
<b-tag v-if="props.row.status !== 'enabled'" :class="props.row.status">
{{ $t(`subscribers.status.${props.row.status}`) }}
</b-tag>
<b-taglist>
<template v-for="l in props.row.lists">
<router-link :to="`/subscribers/lists/${l.id}`" :key="l.id" style="padding-right:0.5em;">
@ -130,7 +126,8 @@
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)">
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'blocklisted': props.row.status === 'blocklisted' }">
{{ props.row.name }}
</a>
</b-table-column>
@ -157,14 +154,14 @@
<b-icon icon="cloud-download-outline" size="is-small" />
</b-tooltip>
</a>
<a :href="`/subscribers/${props.row.id}`" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<a v-if="$can('subscribers:manage')" :href="`/subscribers/${props.row.id}`"
@click.prevent="showEditForm(props.row)" data-cy="btn-edit" :aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a href="#" @click.prevent="deleteSubscriber(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<a v-if="$can('subscribers:manage')" href="#" @click.prevent="deleteSubscriber(props.row)"
data-cy="btn-delete" :aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>

View file

@ -0,0 +1,242 @@
<template>
<form @submit.prevent="onSubmit">
<div class="modal-card content" style="width: auto">
<header class="modal-card-head">
<p v-if="isEditing" class="has-text-grey-light is-size-7">
{{ $t('globals.fields.id') }}: <copy-text :text="`${data.id}`" />
</p>
<h4 v-if="isEditing">
{{ data.name }}
</h4>
<h4 v-else>
{{ $t('users.newUser') }}
</h4>
</header>
<section expanded class="modal-card-body">
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.type')" label-position="on-border">
<b-select v-model="form.type" name="status" required expanded :disabled="isEditing">
<option value="user">
{{ $t('users.type.user') }}
</option>
<option value="api">
{{ $t('users.type.api') }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('globals.fields.status')" label-position="on-border">
<b-select v-model="form.status" name="status" required expanded>
<option value="enabled">
{{ $t('users.status.enabled') }}
</option>
<option value="disabled">
{{ $t('users.status.disabled') }}
</option>
</b-select>
</b-field>
</div>
</div>
<b-field :label="$t('users.username')" label-position="on-border">
<b-input :maxlength="200" v-model="form.username" name="username" ref="focus" autofocus
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
pattern="[a-zA-Z0-9_\-\.]+$" />
</b-field>
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required />
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<template v-if="form.type !== 'api'">
<div class="box">
<b-field>
<b-checkbox v-model="form.passwordLogin" :native-value="true">
{{ $t('users.passwordEnable') }}
</b-checkbox>
</b-field>
<div class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
type="password" name="password" :placeholder="$t('users.password')"
:required="form.passwordLogin && !isEditing" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
</div>
</div>
</div>
</template>
<h5>{{ $tc('users.roles') }}</h5>
<div class="box">
<div class="columns">
<div class="column is-6">
<b-field :label="$tc('users.userRole')" label-position="on-border">
<b-select v-model="form.userRoleId" name="role" required expanded>
<option v-for="r in userRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
<div class="column is-6">
<b-field :label="$tc('users.listRole', 0)" label-position="on-border">
<b-select v-model="form.listRoleId" name="role" expanded>
<option value="">&mdash; {{ $t("globals.terms.none") }} &mdash;</option>
<option v-for="r in listRoles" :value="r.id" :key="r.id">
{{ r.name }}
</option>
</b-select>
</b-field>
</div>
</div>
</div>
<div v-if="apiToken" class="user-api-token">
<p>{{ $t('users.apiOneTimeToken') }}</p>
<copy-text :text="apiToken" />
</div>
</section>
<footer class="modal-card-foot has-text-right">
<b-button @click="$parent.close()">
{{ $t('globals.buttons.close') }}
</b-button>
<b-button v-if="$can('users:manage') && !apiToken" native-type="submit" type="is-primary"
:loading="loading.lists" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</footer>
</div>
</form>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../components/CopyText.vue';
export default Vue.extend({
name: 'UserForm',
components: {
CopyText,
},
props: {
data: { type: Object, default: () => ({}) },
isEditing: { type: Boolean, default: false },
},
data() {
return {
// Binds form input values.
form: {
username: '',
email: '',
name: '',
password: '',
passwordLogin: false,
type: 'user',
status: 'enabled',
},
apiToken: null,
};
},
methods: {
onSubmit() {
if (!this.form.passwordLogin) {
this.form.password = null;
this.form.password2 = null;
}
if (this.isEditing) {
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
this.updateUser();
return;
}
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
this.createUser();
},
createUser() {
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.createUser(form).then((data) => {
this.$emit('finished');
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
// If the user is an API user, show the one-time token.
if (form.type === 'api') {
this.apiToken = data.password;
return;
}
this.$emit('finished');
this.$parent.close();
});
},
updateUser() {
const form = {
...this.form, password_login: this.form.passwordLogin, user_role_id: this.form.userRoleId, list_role_id: this.form.listRoleId || null,
};
this.$api.updateUser({ id: this.data.id, ...form }).then((data) => {
this.$emit('finished');
this.$parent.close();
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
});
},
hasType(t) {
// If the user being edited is API, then the only valid field is API.
// Otherwise, all fields are valid except API.
return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api');
},
},
computed: {
...mapState(['loading', 'userRoles', 'listRoles']),
},
mounted() {
this.form = { ...this.form, ...this.$props.data };
if (this.$props.data.userRole) {
this.form.userRoleId = this.$props.data.userRole.id;
}
this.form.listRoleId = this.$props.data.listRole ? this.$props.data.listRole.id : '';
this.$api.getUserRoles();
this.$api.getListRoles();
this.$nextTick(() => {
this.$refs.focus.focus();
});
},
});
</script>

View file

@ -0,0 +1,95 @@
<template>
<section class="user-profile section-mini">
<b-loading v-if="loading.users" :active="loading.users" :is-full-page="false" />
<h1 class="title">
@{{ data.username }}
</h1>
<b-tag v-if="data.userRole">{{ data.userRole.name }}</b-tag>
<br /><br /><br />
<form @submit.prevent="onSubmit">
<b-field v-if="data.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')"
:disabled="!data.passwordLogin" required autofocus />
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<div v-if="data.passwordLogin" class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password" type="password" name="password"
:placeholder="$t('users.password')" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password2" type="password" name="password2" />
</b-field>
</div>
</div>
<b-field expanded>
<b-button type="is-primary" icon-left="content-save-outline" native-type="submit" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</b-field>
</form>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
name: 'UserProfile',
data() {
return {
form: {},
data: {},
};
},
methods: {
onSubmit() {
const params = {
name: this.form.name,
email: this.form.email,
};
if (this.data.passwordLogin && this.form.password) {
if (this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
params.password = this.form.password;
params.password2 = this.form.password2;
}
this.$api.updateUserProfile(params).then(() => {
this.form.password = '';
this.form.password2 = '';
this.$utils.toast(this.$t('globals.messages.updated', { name: this.data.username }));
});
},
},
mounted() {
this.$api.getUserProfile().then((data) => {
this.data = { ...data };
this.form = { name: data.name, email: data.email };
});
},
computed: {
...mapState(['loading']),
},
});
</script>

View file

@ -0,0 +1,235 @@
<template>
<section class="users">
<header class="columns page-header">
<div class="column is-10">
<h1 class="title is-4">
{{ $t('globals.terms.users') }}
<span v-if="!isNaN(users.length)">({{ users.length }})</span>
</h1>
</div>
<div class="column has-text-right">
<b-field v-if="$can('users:manage')" expanded>
<b-button expanded type="is-primary" icon-left="plus" class="btn-new" @click="showNewForm" data-cy="btn-new">
{{ $t('globals.buttons.new') }}
</b-button>
</b-field>
</div>
</header>
<b-table :data="users" :loading="loading.users" hoverable checkable :checked-rows.sync="checked"
default-sort="createdAt" backend-sorting @sort="onSort" @check-all="onTableCheck" @check="onTableCheck">
<template #top-left>
<div class="columns">
<div class="column is-6">
<form @submit.prevent="getUsers">
<div>
<b-field>
<b-input v-model="queryParams.query" name="query" expanded icon="magnify" ref="query"
data-cy="query" />
<p class="controls">
<b-button native-type="submit" type="is-primary" icon-left="magnify" data-cy="btn-query" />
</p>
</b-field>
</div>
</form>
</div>
</div>
</template>
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
:td-attrs="$utils.tdID">
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.username }}
</a>
<b-tag v-if="props.row.status === 'disabled'">
{{ $t(`users.status.${props.row.status}`) }}
</b-tag>
<div class="has-text-grey is-size-7">
{{ props.row.name }}
<b-tag v-if="props.row.type === 'api'" class="is-small api">
<b-icon icon="code" />
{{ $t(`users.type.${props.row.type}`) }}
</b-tag>
</div>
</b-table-column>
<b-table-column v-slot="props" field="status" :label="$tc('users.role')" header-class="cy-status" sortable
:td-attrs="$utils.tdID">
<router-link :to="{ name: 'userRoles' }">
<b-tag :class="props.row.userRole.id === 1 ? 'enabled' : 'primary'">
<b-icon icon="account-outline" />
{{ props.row.userRole.name }}
</b-tag>
</router-link>
<router-link :to="{ name: 'listRoles' }">
<b-tag v-if="props.row.listRole">
<b-icon icon="newspaper-variant-outline" />
{{ props.row.listRole.name }}
</b-tag>
</router-link>
</b-table-column>
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable
:td-attrs="$utils.tdID">
<div>
<a v-if="props.row.email" :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
{{ props.row.email }}
</a>
<template v-else>
</template>
</div>
</b-table-column>
<b-table-column v-slot="props" field="created_at" :label="$t('globals.fields.createdAt')"
header-class="cy-created_at" sortable>
{{ $utils.niceDate(props.row.createdAt) }}
</b-table-column>
<b-table-column v-slot="props" field="updated_at" :label="$t('globals.fields.updatedAt')"
header-class="cy-updated_at" sortable>
{{ $utils.niceDate(props.row.updatedAt) }}
</b-table-column>
<b-table-column v-slot="props" field="last_login" :label="$t('users.lastLogin')" header-class="cy-updated_at"
sortable>
{{ props.row.loggedinAt ? $utils.niceDate(props.row.loggedinAt, true) : '—' }}
</b-table-column>
<b-table-column v-slot="props" cell-class="actions" align="right">
<div>
<a v-if="$can('users:manage')" href="#" @click.prevent="showEditForm(props.row)" data-cy="btn-edit"
:aria-label="$t('globals.buttons.edit')">
<b-tooltip :label="$t('globals.buttons.edit')" type="is-dark">
<b-icon icon="pencil-outline" size="is-small" />
</b-tooltip>
</a>
<a v-if="$can('users:manage')" href="#" @click.prevent="deleteUser(props.row)" data-cy="btn-delete"
:aria-label="$t('globals.buttons.delete')">
<b-tooltip :label="$t('globals.buttons.delete')" type="is-dark">
<b-icon icon="trash-can-outline" size="is-small" />
</b-tooltip>
</a>
</div>
</b-table-column>
<template #empty v-if="!loading.users">
<empty-placeholder />
</template>
</b-table>
<!-- Add / edit form modal -->
<b-modal scroll="keep" :aria-modal="true" :active.sync="isFormVisible" :width="600" @close="onFormClose">
<user-form :data="curItem" :is-editing="isEditing" @finished="formFinished" />
</b-modal>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
import UserForm from './UserForm.vue';
export default Vue.extend({
components: {
EmptyPlaceholder,
UserForm,
},
data() {
return {
curItem: null,
isEditing: false,
isFormVisible: false,
users: [],
checked: [],
queryParams: {
page: 1,
query: '',
orderBy: 'id',
order: 'asc',
},
};
},
methods: {
onSort(field, direction) {
this.queryParams.orderBy = field;
this.queryParams.order = direction;
this.getUsers();
},
onTableCheck() {
// Disable bulk.all selection if there are no rows checked in the table.
if (this.bulk.checked.length !== this.subscribers.total) {
this.bulk.all = false;
}
},
// Show the edit form.
showEditForm(item) {
this.curItem = item;
this.isFormVisible = true;
this.isEditing = true;
},
// Show the new form.
showNewForm() {
this.curItem = {};
this.isFormVisible = true;
this.isEditing = false;
},
formFinished() {
this.getUsers();
},
onFormClose() {
if (this.$route.params.id) {
this.$router.push({ name: 'users' });
}
},
getUsers() {
this.$api.queryUsers({
query: this.queryParams.query.replace(/[^\p{L}\p{N}\s]/gu, ' '),
order_by: this.queryParams.orderBy,
order: this.queryParams.order,
}).then((resp) => {
this.users = resp;
});
},
deleteUser(item) {
this.$utils.confirm(
this.$t('globals.messages.confirm'),
() => {
this.$api.deleteUser(item.id).then(() => {
this.getUsers();
this.$utils.toast(this.$t('globals.messages.deleted', { name: item.name }));
});
},
);
},
},
computed: {
...mapState(['loading', 'settings']),
},
mounted() {
if (this.$route.params.id) {
this.$api.getUser(parseInt(this.$route.params.id, 10)).then((data) => {
this.showEditForm(data);
});
} else {
this.getUsers();
}
},
});
</script>

View file

@ -1,5 +1,44 @@
<template>
<div class="items">
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.security.enableOIDC')" :message="$t('settings.security.OIDCHelp')">
<b-switch v-model="data['security.oidc']['enabled']" name="security.oidc" />
</b-field>
</div>
<div class="column is-8">
<b-field :label="$t('settings.security.OIDCURL')" label-position="on-border">
<div>
<b-input v-model="data['security.oidc']['provider_url']" name="oidc.provider_url"
placeholder="https://login.yoursite.com" :disabled="!data['security.oidc']['enabled']" :maxlength="300"
required type="url" pattern="https?://.*" />
<div class="spaced-links is-size-7 mt-2" :class="{ 'disabled': !data['security.oidc']['enabled'] }">
<a href="#" @click.prevent="() => setProvider(n, 'google')">Google</a>
<a href="#" @click.prevent="() => setProvider(n, 'github')">GitHub</a>
<a href="#" @click.prevent="() => setProvider(n, 'microsoft')">Microsoft</a>
<a href="#" @click.prevent="() => setProvider(n, 'apple')">Apple</a>
</div>
</div>
</b-field>
<b-field :label="$t('settings.security.OIDCClientID')" label-position="on-border">
<b-input v-model="data['security.oidc']['client_id']" name="oidc.client_id" ref="client_id"
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.OIDCClientSecret')" label-position="on-border">
<b-input v-model="data['security.oidc']['client_secret']" name="oidc.client_secret" type="password"
:disabled="!data['security.oidc']['enabled']" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.OIDCRedirectURL')">
<code><copy-text :text="`${serverConfig.root_url}/auth/oidc`" /></code>
</b-field>
</div>
</div>
<hr />
<div class="columns">
<div class="column is-4">
<b-field :label="$t('settings.security.enableCaptcha')" :message="$t('settings.security.enableCaptchaHelp')">
@ -9,8 +48,8 @@
<div class="column is-8">
<b-field :label="$t('settings.security.captchaKey')" label-position="on-border"
:message="$t('settings.security.captchaKeyHelp')">
<b-input v-model="data['security.captcha_key']" name="captcha_key" :disabled="!data['security.enable_captcha']"
:maxlength="200" required />
<b-input v-model="data['security.captcha_key']" name="captcha_key"
:disabled="!data['security.enable_captcha']" :maxlength="200" required />
</b-field>
<b-field :label="$t('settings.security.captchaSecret')" label-position="on-border">
<b-input v-model="data['security.captcha_secret']" name="captcha_secret" type="password"
@ -23,14 +62,49 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import CopyText from '../../components/CopyText.vue';
const OIDC_PROVIDERS = {
google: 'https://accounts.google.com',
github: 'https://token.actions.githubusercontent.com',
microsoft: 'https://login.microsoftonline.com/{TENANT_HERE}/v2.0',
apple: 'https://appleid.apple.com',
};
export default Vue.extend({
components: {
CopyText,
},
props: {
form: {
type: Object, default: () => { },
},
},
computed: {
...mapState(['serverConfig']),
version() {
return import.meta.env.VUE_APP_VERSION;
},
isMobile() {
return this.windowWidth <= 768;
},
},
methods: {
setProvider(n, provider) {
this.$set(this.data['security.oidc'], 'provider_url', OIDC_PROVIDERS[provider]);
this.$nextTick(() => {
this.$refs.client_id.focus();
});
},
},
data() {
return {
data: this.form,

View file

@ -66,7 +66,7 @@
</b-field>
</div>
</div><!-- auth -->
<div class="smtp-shortcuts is-size-7">
<div class="spaced-links is-size-7">
<a href="#" @click.prevent="() => fillSettings(n, 'gmail')">Gmail</a>
<a href="#" @click.prevent="() => fillSettings(n, 'ses')">Amazon SES</a>
<a href="#" @click.prevent="() => fillSettings(n, 'mailgun')">Mailgun</a>

View file

@ -28,6 +28,9 @@ export default defineConfig(({ _, mode }) => {
'^/(api|webhooks|subscription|public|health)': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},
'^/admin/login': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},
'^/(admin\/custom\.(css|js))': {
target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000',
},

9
go.mod
View file

@ -4,6 +4,7 @@ go 1.20
require (
github.com/Masterminds/sprig/v3 v3.2.3
github.com/coreos/go-oidc/v3 v3.9.0
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-message v0.16.0
github.com/gdgvda/cron v0.2.0
@ -31,7 +32,10 @@ require (
github.com/spf13/pflag v1.0.5
github.com/yuin/goldmark v1.6.0
github.com/zerodha/easyjson v1.0.0
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0
github.com/zerodha/simplesessions/v3 v3.0.0
golang.org/x/mod v0.17.0
golang.org/x/oauth2 v0.13.0
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b
)
@ -40,7 +44,9 @@ require (
github.com/Masterminds/semver/v3 v3.2.0 // indirect
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/huandu/xstrings v1.4.0 // indirect
github.com/imdario/mergo v0.3.14 // indirect
@ -63,6 +69,9 @@ require (
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
)
replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.8

36
go.sum
View file

@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo=
github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@ -20,6 +22,8 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gdgvda/cron v0.2.0 h1:oX8qdLZq4tC5StnCsZsTNs2BIzaRjcjmPZ4o+BArKX4=
github.com/gdgvda/cron v0.2.0/go.mod h1:VEwidZXB255kESB5DcUGRWTYZS8KkOBYD1YBn8Wiyx8=
github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
@ -28,7 +32,13 @@ github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -124,10 +134,11 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
@ -137,7 +148,12 @@ github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zerodha/easyjson v1.0.0 h1:3u1lvS8C+8ntnb4lXHc7ZzfQ8txUdzBAH5t9AwF7bUs=
github.com/zerodha/easyjson v1.0.0/go.mod h1:mA8d8Xs8Yp4Q95ppRb4dRGROERgKSLQIK9Y7iuC5mog=
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0 h1:50BNRW/VYOgCf5v6vbhKMT40sFA+yZ7xUrdM/vbI1G8=
github.com/zerodha/simplesessions/stores/postgres/v3 v3.0.0/go.mod h1:PifZh0lGfmx4sN3+YvDCjkIDrTzZoILL9jkczV1SsiA=
github.com/zerodha/simplesessions/v3 v3.0.0 h1:seHwxVNnlCbp5nG8GFxSsRUdiHnfb39QdEW3J536O9Y=
github.com/zerodha/simplesessions/v3 v3.0.0/go.mod h1:lAK+CJmZRlbvfq+OnkB8Iyf6LWgjzvUuWYKX1XA51P0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
@ -148,6 +164,7 @@ golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@ -155,9 +172,12 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY=
golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -176,6 +196,7 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
@ -185,13 +206,20 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0=
gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -130,6 +130,7 @@
"forms.title": "Forms",
"globals.buttons.add": "Add",
"globals.buttons.addNew": "Add new",
"globals.buttons.toggleSelect": "Toggle selection",
"globals.buttons.back": "Back",
"globals.buttons.cancel": "Cancel",
"globals.buttons.clear": "Clear",
@ -151,6 +152,7 @@
"globals.buttons.save": "Save",
"globals.buttons.saveChanges": "Save changes",
"globals.buttons.view": "View",
"globals.buttons.manage": "Manage",
"globals.days.0": "Sun",
"globals.days.1": "Sun",
"globals.days.2": "Mon",
@ -178,6 +180,7 @@
"globals.messages.errorCreating": "Error creating {name}: {error}",
"globals.messages.errorDeleting": "Error deleting {name}: {error}",
"globals.messages.errorFetching": "Error fetching {name}: {error}",
"globals.messages.permissionDenied": "Permission denied: {name}",
"globals.messages.errorInvalidIDs": "One or more IDs are invalid: {error}",
"globals.messages.errorUUID": "Error generating UUID: {error}",
"globals.messages.errorUpdating": "Error updating {name}: {error}",
@ -230,6 +233,8 @@
"globals.terms.tag": "Tag | Tags",
"globals.terms.tags": "Tags",
"globals.terms.template": "Template | Templates",
"globals.terms.users": "Users",
"globals.terms.user": "User | Users",
"globals.terms.templates": "Templates",
"globals.terms.tx": "Transactional | Transactional",
"globals.terms.year": "Year | Years",
@ -355,6 +360,13 @@
"public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.security.enableOIDC": "Enable OIDC SSO",
"settings.security.OIDCHelp": "Enable OpenID Connect OAuth2 login via an OAuth provider.",
"settings.security.OIDCWarning": "When OIDC is enabled, default password login is disabled. Invalid config can lock you out.",
"settings.security.OIDCURL": "Provider URL",
"settings.security.OIDCClientID": "Client ID",
"settings.security.OIDCClientSecret": "Client secret",
"settings.security.OIDCRedirectURL": "Redirect URL for oAuth provider",
"settings.appearance.adminHelp": "Custom CSS to apply to the admin UI.",
"settings.appearance.adminName": "Admin",
"settings.appearance.customCSS": "Custom CSS",
@ -587,5 +599,38 @@
"templates.rawHTML": "Raw HTML",
"templates.subject": "Subject",
"users.login": "Login",
"users.logout": "Logout"
"users.role": "Role | Roles",
"users.roles": "Roles",
"users.userRole": "User role | User roles",
"users.userRoles": "User roles",
"users.listRole": "List roles | List role",
"users.listRoles": "List roles",
"users.newUserRole": "New user role",
"users.newListRole": "New list role",
"users.listPerms": "List permissions",
"users.listPermsWarning": "lists:get_all or lists:manage_all are enabled which overrides per-list permissions",
"users.perms": "Permissions",
"users.roleGroup": "Group",
"users.loginOIDC": "Login with {name}",
"users.logout": "Logout",
"users.profile": "Profile",
"users.lastLogin": "Last login",
"users.newUser": "New user",
"users.type": "Type",
"users.type.user": "User",
"users.type.super": "Super Admin",
"users.type.api": "API",
"users.status.enabled": "Enabled",
"users.status.disabled": "Disabled",
"users.username": "Username",
"users.usernameHelp": "Used with password login",
"users.password": "Password",
"users.invalidLogin": "Invalid login or password",
"users.invalidRequest": "Invalid auth request",
"users.passwordRepeat": "Repeat password",
"users.passwordEnable": "Enable password login",
"users.passwordMismatch": "Passwords don't match",
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
"users.needSuper": "User(s) couldn't updated. There has to be at least one active Super Admin user.",
"users.cantDeleteRole": "Cannot delete role that is in use."
}

372
internal/auth/auth.go Normal file
View file

@ -0,0 +1,372 @@
package auth
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/zerodha/simplesessions/stores/postgres/v3"
"github.com/zerodha/simplesessions/v3"
"golang.org/x/oauth2"
)
const (
// UserKey is the key on which the User profile is set on echo handlers.
UserKey = "auth_user"
SessionKey = "auth_session"
SuperAdminRoleID = 1
)
const (
sessTypeNative = "native"
sessTypeOIDC = "oidc"
)
type OIDCclaim struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Sub string `json:"sub"`
Picture string `json:"picture"`
}
type OIDCConfig struct {
Enabled bool `json:"enabled"`
ProviderURL string `json:"provider_url"`
RedirectURL string `json:"redirect_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
type BasicAuthConfig struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
OIDC OIDCConfig
BasicAuth BasicAuthConfig
}
// Callbacks takes two callback functions required by simplesessions.
type Callbacks struct {
SetCookie func(cookie *http.Cookie, w interface{}) error
GetCookie func(name string, r interface{}) (*http.Cookie, error)
GetUser func(id int) (models.User, error)
}
type Auth struct {
apiUsers map[string]models.User
sync.RWMutex
cfg Config
oauthCfg oauth2.Config
verifier *oidc.IDTokenVerifier
sess *simplesessions.Manager
sessStore *postgres.Store
cb *Callbacks
log *log.Logger
}
func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) {
a := &Auth{
cfg: cfg,
cb: cb,
log: lo,
apiUsers: map[string]models.User{},
}
// Initialize OIDC.
if cfg.OIDC.Enabled {
provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL)
if err != nil {
lo.Printf("error initializing OIDC OAuth provider: %v", err)
} else {
a.verifier = provider.Verifier(&oidc.Config{
ClientID: cfg.OIDC.ClientID,
})
a.oauthCfg = oauth2.Config{
ClientID: cfg.OIDC.ClientID,
ClientSecret: cfg.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: cfg.OIDC.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
}
}
// Initialize session manager.
a.sess = simplesessions.New(simplesessions.Options{
EnableAutoCreate: false,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
IsHTTPOnly: true,
MaxAge: time.Hour * 24 * 7,
},
})
st, err := postgres.New(postgres.Opt{}, db)
if err != nil {
return nil, err
}
a.sessStore = st
a.sess.UseStore(st)
a.sess.SetCookieHooks(cb.GetCookie, cb.SetCookie)
// Prune dead sessions from the DB periodically.
go func() {
if err := st.Prune(); err != nil {
lo.Printf("error pruning login sessions: %v", err)
}
time.Sleep(time.Hour * 12)
}()
return a, nil
}
// CacheAPIUsers caches API users for authenticating requests. It wipes
// the existing cache every time and is meant for syncing all API users
// in the database in one shot.
func (o *Auth) CacheAPIUsers(users []models.User) {
o.Lock()
o.apiUsers = map[string]models.User{}
for _, u := range users {
o.apiUsers[u.Username] = u
}
o.Unlock()
}
// CacheAPIUser caches an API user for authenticating requests.
func (o *Auth) CacheAPIUser(u models.User) {
o.Lock()
o.apiUsers[u.Username] = u
o.Unlock()
}
// GetAPIToken validates an API user+token.
func (o *Auth) GetAPIToken(user string, token string) (models.User, bool) {
o.RLock()
t, ok := o.apiUsers[user]
o.RUnlock()
if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(token)) != 1 {
return models.User{}, false
}
return t, true
}
// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to.
func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
return o.oauthCfg.AuthCodeURL(state, oidc.Nonce(nonce))
}
// ExchangeOIDCToken takes an OIDC authorization code (recieved via redirect from the OIDC provider),
// validates it, and returns an OIDC token for subsequent auth.
func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, OIDCclaim, error) {
tk, err := o.oauthCfg.Exchange(context.TODO(), code)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err))
}
rawIDTk, ok := tk.Extra("id_token").(string)
if !ok {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.")
}
idTk, err := o.verifier.Verify(context.TODO(), rawIDTk)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err))
}
if idTk.Nonce != nonce {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match")
}
var claims OIDCclaim
if err := idTk.Claims(&claims); err != nil {
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
}
return rawIDTk, claims, nil
}
// Middleware is the HTTP middleware used for wrapping HTTP handlers registered on the echo router.
// It authorizes token (BasicAuth/token) based and cookie based sessions and on successful auth,
// sets the authenticated User{} on the echo context on the key UserKey. On failure, it sets an Error{}
// instead on the same key.
func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// It's an `Authorization` header request.
hdr := strings.TrimSpace(c.Request().Header.Get("Authorization"))
// If cookie is set, ignore BasicAuth. This is to preserve backwards compatibility
// in v3 -> v4 upgrade where the user browser sessions would still have old
// BasicAuth credentials, which no longer work in the new system which expects
// session cookies instead, which causes a redirect loop despite loggin in and session
// cookies being set.
//
// TODO: This should be removed in a future version.
if c := strings.TrimSpace(c.Request().Header.Get("Cookie")); strings.Contains(c, "session=") {
hdr = ""
}
if len(hdr) > 0 {
key, token, err := parseAuthHeader(hdr)
if err != nil {
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, err.Error()))
return next(c)
}
// Validate the token.
user, ok := o.GetAPIToken(key, token)
if !ok {
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserKey, user)
return next(c)
}
// Is it a cookie based session?
sess, user, err := o.validateSession(c)
if err != nil {
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserKey, user)
c.Set(SessionKey, sess)
return next(c)
}
}
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
return func(c echo.Context) error {
u, ok := c.Get(UserKey).(models.User)
if !ok {
c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// If the current user is a Super Admin user, do no checks.
if u.UserRole.ID == SuperAdminRoleID {
return next(c)
}
// Check if the current handler's permission is in the user's permission map.
var (
has = false
perm = ""
)
for _, perm = range perms {
if _, ok := u.PermissionsMap[perm]; ok {
has = true
break
}
}
if !has {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
}
return next(c)
}
}
// SaveSession creates and sets a session (post successful login/auth).
func (o *Auth) SaveSession(u models.User, oidcToken string, c echo.Context) error {
sess, err := o.sess.NewSession(c, c)
if err != nil {
o.log.Printf("error creating login session: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
}
if err := sess.SetMulti(map[string]interface{}{"user_id": u.ID, "oidc_token": oidcToken}); err != nil {
o.log.Printf("error setting login session: %v", err)
return echo.NewHTTPError(http.StatusInternalServerError, "error creating session")
}
return nil
}
func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, models.User, error) {
// Cookie session.
sess, err := o.sess.Acquire(nil, c, c)
if err != nil {
return nil, models.User{}, echo.NewHTTPError(http.StatusForbidden, err.Error())
}
// Get the session variables.
vars, err := sess.GetMulti("user_id", "oidc_token")
if err != nil {
return nil, models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
// Validate the user ID in the session.
userID, err := o.sessStore.Int(vars["user_id"], nil)
if err != nil || userID < 1 {
o.log.Printf("error fetching session user ID: %v", err)
return nil, models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
// Fetch user details from the database.
user, err := o.cb.GetUser(userID)
if err != nil {
o.log.Printf("error fetching session user: %v", err)
}
return sess, user, err
}
// parseAuthHeader parses the Authorization header and returns the api_key and access_token.
func parseAuthHeader(h string) (string, string, error) {
const authBasic = "Basic"
const authToken = "token"
var (
pair []string
delim = ":"
)
if strings.HasPrefix(h, authToken) {
// token api_key:access_token.
pair = strings.SplitN(strings.Trim(h[len(authToken):], " "), delim, 2)
} else if strings.HasPrefix(h, authBasic) {
// HTTP BasicAuth. This is supported for backwards compatibility.
payload, err := base64.StdEncoding.DecodeString(string(strings.Trim(h[len(authBasic):], " ")))
if err != nil {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "invalid Base64 value in Basic Authorization header")
}
pair = strings.SplitN(string(payload), delim, 2)
} else {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "unknown Authorization scheme")
}
if len(pair) < 2 {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "api_key:token missing")
}
if len(pair[0]) == 0 || len(pair[1]) == 0 {
return "", "", echo.NewHTTPError(http.StatusBadRequest, "empty `api_key` or `token`")
}
return pair[0], pair[1], nil
}

View file

@ -10,10 +10,10 @@ import (
)
// GetLists gets all lists optionally filtered by type.
func (c *Core) GetLists(typ string) ([]models.List, error) {
func (c *Core) GetLists(typ string, getAll bool, permittedIDs []int) ([]models.List, error) {
out := []models.List{}
if err := c.q.GetLists.Select(&out, typ, "id"); err != nil {
if err := c.q.GetLists.Select(&out, typ, "id", getAll, pq.Array(permittedIDs)); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
@ -36,7 +36,7 @@ func (c *Core) GetLists(typ string) ([]models.List, error) {
// QueryLists gets multiple lists based on multiple query params. Along with the paginated and sliced
// results, the total number of lists in the DB is returned.
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, offset, limit int) ([]models.List, int, error) {
func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy, order string, getAll bool, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
_ = c.refreshCache(matListSubStats, false)
if tags == nil {
@ -47,7 +47,7 @@ func (c *Core) QueryLists(searchStr, typ, optin string, tags []string, orderBy,
out = []models.List{}
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
)
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), offset, limit); err != nil {
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, pq.StringArray(tags), getAll, pq.Array(permittedIDs), offset, limit); err != nil {
c.log.Printf("error fetching lists: %v", err)
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
@ -82,7 +82,7 @@ func (c *Core) GetList(id int, uuid string) (models.List, error) {
var res []models.List
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, nil)
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, 0, 1); err != nil {
if err := c.db.Select(&res, stmt, id, uu, queryStr, "", "", pq.StringArray{}, true, nil, 0, 1); err != nil {
c.log.Printf("error fetching lists: %v", err)
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))

163
internal/core/roles.go Normal file
View file

@ -0,0 +1,163 @@
package core
import (
"encoding/json"
"net/http"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
)
// GetRoles retrieves all roles.
func (c *Core) GetRoles() ([]models.Role, error) {
out := []models.Role{}
if err := c.q.GetUserRoles.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
return out, nil
}
// GetListRoles retrieves all list roles.
func (c *Core) GetListRoles() ([]models.ListRole, error) {
out := []models.ListRole{}
if err := c.q.GetListRoles.Select(&out); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
}
// Unmarshall the nested list permissions, if any.
for n, r := range out {
if r.ListsRaw == nil {
continue
}
if err := json.Unmarshal(r.ListsRaw, &out[n].Lists); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", r.ID, err)
}
}
return out, nil
}
// CreateRole creates a new role.
func (c *Core) CreateRole(r models.Role) (models.Role, error) {
var out models.Role
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeUser, pq.Array(r.Permissions)); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil
}
// CreateListRole creates a new list role.
func (c *Core) CreateListRole(r models.ListRole) (models.ListRole, error) {
var out models.ListRole
if err := c.q.CreateRole.Get(&out, r.Name, models.RoleTypeList, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return out, nil
}
// UpsertListPermissions upserts permission for a role.
func (c *Core) UpsertListPermissions(roleID int, lp []models.ListPermission) error {
var (
listIDs = make([]int, 0, len(lp))
listPerms = make([][]string, 0, len(lp))
)
for _, p := range lp {
if len(p.Permissions) == 0 {
continue
}
listIDs = append(listIDs, p.ID)
// For the Postgres array unnesting query to work, all permissions arrays should
// have equal number of entries. Add "" in case there's only one of either list:get or list:manage
perms := make([]string, 2)
copy(perms[:], p.Permissions[:])
listPerms = append(listPerms, perms)
}
if _, err := c.q.UpsertListPermissions.Exec(roleID, pq.Array(listIDs), pq.Array(listPerms)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteListPermission deletes a list permission entry from a role.
func (c *Core) DeleteListPermission(roleID, listID int) error {
if _, err := c.q.DeleteListPermission.Exec(roleID, listID); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
}
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}
// UpdateUserRole updates a given role.
func (c *Core) UpdateUserRole(id int, r models.Role) (models.Role, error) {
var out models.Role
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array(r.Permissions)); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.userRole}", "error", pqErrMsg(err)))
}
if out.ID == 0 {
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.userRole}"))
}
return out, nil
}
// UpdateListRole updates a given role.
func (c *Core) UpdateListRole(id int, r models.ListRole) (models.ListRole, error) {
var out models.ListRole
if err := c.q.UpdateRole.Get(&out, id, r.Name, pq.Array([]string{})); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
if out.ID == 0 {
return out, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.notFound", "name", "{users.listRole}"))
}
if err := c.UpsertListPermissions(out.ID, r.Lists); err != nil {
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{users.listRole}", "error", pqErrMsg(err)))
}
return out, nil
}
// DeleteRole deletes a given role.
func (c *Core) DeleteRole(id int) error {
if _, err := c.q.DeleteRole.Exec(id); err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "users_role_id_fkey" {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.cantDeleteRole"))
}
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{users.role}", "error", pqErrMsg(err)))
}
return nil
}

View file

@ -43,6 +43,27 @@ func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, err
return out[0], nil
}
// HasSubscriberLists checks if the given subscribers have at least one of the given lists.
func (c *Core) HasSubscriberLists(subIDs []int, listIDs []int) (map[int]bool, error) {
res := []struct {
SubID int `db:"subscriber_id"`
Has bool `db:"has"`
}{}
if err := c.q.HasSubscriberLists.Select(&res, pq.Array(subIDs), pq.Array(listIDs)); err != nil {
c.log.Printf("error fetching subscriber: %v", err)
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
}
out := make(map[int]bool, len(res))
for _, r := range res {
out[r.SubID] = r.Has
}
return out, nil
}
// GetSubscribersByEmail fetches a subscriber by one of the given params.
func (c *Core) GetSubscribersByEmail(emails []string) (models.Subscribers, error) {
var out models.Subscribers

214
internal/core/users.go Normal file
View file

@ -0,0 +1,214 @@
package core
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
"gopkg.in/volatiletech/null.v6"
)
// GetUsers retrieves all users.
func (c *Core) GetUsers() ([]models.User, error) {
out, err := c.getUsers(0, "", "")
return out, err
}
// GetUser retrieves a specific user based on any one given identifier.
func (c *Core) GetUser(id int, username, email string) (models.User, error) {
out, err := c.getUsers(id, username, strings.ToLower(email))
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return out[0], nil
}
// CreateUser creates a new user.
func (c *Core) CreateUser(u models.User) (models.User, error) {
var id int
// If it's an API user, generate a random token for password
// and set the e-mail to default.
if u.Type == models.UserTypeAPI {
// Generate a random admin password.
tk, err := utils.GenerateRandomString(32)
if err != nil {
return models.User{}, err
}
u.Email = null.String{String: u.Username + "@api", Valid: true}
u.PasswordLogin = false
u.Password = null.String{String: tk, Valid: true}
}
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
// Hide the password field in the response except for when the user type is an API token,
// where the frontend shows the token on the UI just once.
if u.Type != models.UserTypeAPI {
u.Password = null.String{Valid: false}
}
out, err := c.GetUser(id, "", "")
return out, err
}
// UpdateUser updates a given user.
func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
listRoleID := 0
if u.ListRoleID == nil {
listRoleID = -1
} else {
listRoleID = *u.ListRoleID
}
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.UserRoleID, listRoleID, u.Status)
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.User{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
}
out, err := c.GetUser(id, "", "")
return out, err
}
// UpdateUserProfile updates the basic fields of a given uesr (name, email, password).
func (c *Core) UpdateUserProfile(id int, u models.User) (models.User, error) {
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.User{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
}
return c.GetUser(id, "", "")
}
// UpdateUserLogin updates a user's record post-login.
func (c *Core) UpdateUserLogin(id int, avatar string) error {
if _, err := c.q.UpdateUserLogin.Exec(id, avatar); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
return nil
}
// DeleteUsers deletes a given user.
func (c *Core) DeleteUsers(ids []int) error {
res, err := c.q.DeleteUsers.Exec(pq.Array(ids))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if num, err := res.RowsAffected(); err != nil || num == 0 {
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("users.needSuper"))
}
return nil
}
// LoginUser attempts to log the given user_id in by matching the password.
func (c *Core) LoginUser(username, password string) (models.User, error) {
var out models.User
if err := c.q.LoginUser.Get(&out, username, password); err != nil {
if err == sql.ErrNoRows {
return out, echo.NewHTTPError(http.StatusForbidden,
c.i18n.T("users.invalidLogin"))
}
return out, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
return out, nil
}
func (c *Core) getUsers(id int, username, email string) ([]models.User, error) {
out := []models.User{}
if err := c.q.GetUsers.Select(&out, id, username, email); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
}
if len(out) == 0 {
return nil, errors.New("user not found")
}
for n, u := range out {
u := u
if u.Password.String != "" {
u.HasPassword = true
u.PasswordLogin = true
}
if u.Type == models.UserTypeAPI {
u.Email = null.String{}
}
u.UserRole.ID = u.UserRoleID
u.UserRole.Name = u.UserRoleName
u.UserRole.Permissions = u.UserRolePerms
u.UserRoleID = 0
// Prepare lookup maps.
u.ListPermissionsMap = make(map[int]map[string]struct{})
u.PermissionsMap = make(map[string]struct{})
for _, p := range u.UserRolePerms {
u.PermissionsMap[p] = struct{}{}
}
if u.ListRoleID != nil {
// Unmarshall the raw list perms map.
var listPerms []models.ListPermission
if u.ListsPermsRaw != nil {
if err := json.Unmarshal(*u.ListsPermsRaw, &listPerms); err != nil {
c.log.Printf("error unmarshalling list permissions for role %d: %v", u.ID, err)
}
}
u.ListRole = &models.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
for _, p := range listPerms {
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
for _, perm := range p.Permissions {
u.ListPermissionsMap[p.ID][perm] = struct{}{}
// List IDs with get / manage permissions.
if perm == "list:get" {
u.GetListIDs = append(u.GetListIDs, p.ID)
}
if perm == "list:manage" {
u.ManageListIDs = append(u.ManageListIDs, p.ID)
}
}
}
}
out[n] = u
}
return out, nil
}

View file

@ -0,0 +1,151 @@
package migrations
import (
"encoding/json"
"fmt"
"log"
"os"
"github.com/jmoiron/sqlx"
"github.com/knadh/koanf/v2"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
)
// V4_0_0 performs the DB migrations.
func V4_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger) error {
if _, err := db.Exec(`
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
CREATE TYPE user_type AS ENUM ('user', 'api');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
CREATE TYPE role_type AS ENUM ('user', 'list');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS roles_idx ON roles (parent_id, list_id);
CREATE UNIQUE INDEX IF NOT EXISTS roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_login BOOLEAN NOT NULL DEFAULT false,
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT NOT NULL PRIMARY KEY,
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at);
`); err != nil {
return err
}
// Insert new preference settings.
if _, err := db.Exec(`
INSERT INTO settings (key, value) VALUES('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}') ON CONFLICT DO NOTHING;
`); err != nil {
return err
}
// Insert superuser role.
pmRaw, err := fs.Read("/permissions.json")
if err != nil {
lo.Fatalf("error reading permissions file: %v", err)
}
permGroups := []struct {
Group string `json:"group"`
Permissions []string `json:"permissions"`
}{}
if err := json.Unmarshal(pmRaw, &permGroups); err != nil {
lo.Fatalf("error loading permissions file: %v", err)
}
perms := []string{}
for _, group := range permGroups {
for _, p := range group.Permissions {
perms = append(perms, p)
}
}
if _, err := db.Exec(`INSERT INTO roles (type, name, permissions) VALUES('user', 'Super Admin', $1) ON CONFLICT DO NOTHING`, pq.Array(perms)); err != nil {
return 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 := db.Exec(`
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, status) VALUES($1, true, CRYPT($2, GEN_SALT('bf')), $3, $4, 'user', 1, 'enabled') ON CONFLICT DO NOTHING;
`, user, password, user+"@listmonk", user); err != nil {
return err
}
if typ == "auto-generated" {
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)
}
return nil
}

53
internal/utils/utils.go Normal file
View file

@ -0,0 +1,53 @@
package utils
import (
"crypto/rand"
"net/mail"
"net/url"
"path"
"strings"
)
// ValidateEmail validates whether the given string is a correctly formed e-mail address.
func ValidateEmail(email string) bool {
// Since `mail.ParseAddress` parses an email address which can also contain an optional name component,
// here we check if incoming email string is same as the parsed email.Address. So this eliminates
// any valid email address with name and also valid address with empty name like `<abc@example.com>`.
em, err := mail.ParseAddress(email)
if err != nil || em.Address != email {
return false
}
return true
}
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
func GenerateRandomString(n int) (string, error) {
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
var bytes = make([]byte, n)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for k, v := range bytes {
bytes[k] = dictionary[v%byte(len(dictionary))]
}
return string(bytes), nil
}
// SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI.
// This is used for cleaning "next" redirect URLs/URIs to prevent open redirects.
func SanitizeURI(u string) string {
u = strings.TrimSpace(u)
if u == "" {
return "/"
}
p, err := url.Parse(u)
if err != nil || strings.Contains(p.Path, "..") {
return "/"
}
return path.Clean(p.Path)
}

View file

@ -56,11 +56,15 @@ const (
ListOptinDouble = "double"
// User.
UserTypeSuperadmin = "superadmin"
UserTypeUser = "user"
UserTypeAPI = "api"
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
// Role.
RoleTypeUser = "user"
RoleTypeList = "list"
// BaseTpl is the name of the base template.
BaseTpl = "base"
@ -148,11 +152,82 @@ type Base struct {
type User struct {
Base
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"-"`
Type string `json:"type"`
Status string `json:"status"`
Username string `db:"username" json:"username"`
// For API users, this is the plaintext API token.
Password null.String `db:"password" json:"password,omitempty"`
PasswordLogin bool `db:"password_login" json:"password_login"`
Email null.String `db:"email" json:"email"`
Name string `db:"name" json:"name"`
Type string `db:"type" json:"type"`
Status string `db:"status" json:"status"`
Avatar null.String `db:"avatar" json:"avatar"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
// Role struct {
// ID int `db:"-" json:"id"`
// Name string `db:"-" json:"name"`
// Permissions []string `db:"-" json:"permissions"`
// Lists []ListPermission `db:"-" json:"lists"`
// } `db:"-" json:"role"`
// Filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
PermissionsMap map[string]struct{} `db:"-" json:"-"`
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
GetListIDs []int `db:"-" json:"-"`
ManageListIDs []int `db:"-" json:"-"`
HasPassword bool `db:"-" json:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
// Subscriber represents an e-mail subscriber.
@ -266,6 +341,7 @@ type Campaign struct {
// List of media (attachment) IDs obtained from the next-campaign query
// while sending a campaign.
MediaIDs pq.Int64Array `json:"-" db:"media_id"`
// Fetched bodies of the attachments.
Attachments []Attachment `json:"-" db:"-"`
@ -738,3 +814,45 @@ func (h Headers) Value() (driver.Value, error) {
return "[]", nil
}
func (u *User) HasPerm(perm string) bool {
_, ok := u.PermissionsMap[perm]
return ok
}
// FilterListsByPerm returns list IDs filtered by either of the given perms.
func (u *User) FilterListsByPerm(listIDs []int, get, manage bool) []int {
// If the user has full list management permission,
// no further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return listIDs
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return listIDs
}
}
out := make([]int, 0, len(listIDs))
// Go through every list ID.
for _, id := range listIDs {
// Check if it exists in the map.
if l, ok := u.ListPermissionsMap[id]; ok {
// Check if any of the given permission exists for it.
if get {
if _, ok := l[PermListGet]; ok {
out = append(out, id)
}
} else if manage {
if _, ok := l[PermListManage]; ok {
out = append(out, id)
}
}
}
}
return out
}

View file

@ -18,6 +18,7 @@ type Queries struct {
UpsertSubscriber *sqlx.Stmt `query:"upsert-subscriber"`
UpsertBlocklistSubscriber *sqlx.Stmt `query:"upsert-blocklist-subscriber"`
GetSubscriber *sqlx.Stmt `query:"get-subscriber"`
HasSubscriberLists *sqlx.Stmt `query:"has-subscriber-list"`
GetSubscribersByEmails *sqlx.Stmt `query:"get-subscribers-by-emails"`
GetSubscriberLists *sqlx.Stmt `query:"get-subscriber-lists"`
GetSubscriptions *sqlx.Stmt `query:"get-subscriptions"`
@ -107,6 +108,23 @@ type Queries struct {
DeleteBounces *sqlx.Stmt `query:"delete-bounces"`
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
GetDBInfo string `query:"get-db-info"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateUserProfile *sqlx.Stmt `query:"update-user-profile"`
UpdateUserLogin *sqlx.Stmt `query:"update-user-login"`
DeleteUsers *sqlx.Stmt `query:"delete-users"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"`
CreateRole *sqlx.Stmt `query:"create-role"`
GetUserRoles *sqlx.Stmt `query:"get-user-roles"`
GetListRoles *sqlx.Stmt `query:"get-list-roles"`
UpdateRole *sqlx.Stmt `query:"update-role"`
DeleteRole *sqlx.Stmt `query:"delete-role"`
UpsertListPermissions *sqlx.Stmt `query:"upsert-list-permissions"`
DeleteListPermission *sqlx.Stmt `query:"delete-list-permission"`
}
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions

View file

@ -40,6 +40,13 @@ type Settings struct {
SecurityCaptchaKey string `json:"security.captcha_key"`
SecurityCaptchaSecret string `json:"security.captcha_secret"`
OIDC struct {
Enabled bool `json:"enabled"`
ProviderURL string `json:"provider_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
} `json:"security.oidc"`
UploadProvider string `json:"upload.provider"`
UploadExtensions []string `json:"upload.extensions"`
UploadFilesystemUploadPath string `json:"upload.filesystem.upload_path"`

75
permissions.json Normal file
View file

@ -0,0 +1,75 @@
[
{
"group": "lists",
"permissions":
[
"lists:get_all",
"lists:manage_all"
]
},
{
"group": "subscribers",
"permissions":
[
"subscribers:get",
"subscribers:get_all",
"subscribers:manage",
"subscribers:import",
"subscribers:sql_query",
"tx:send"
]
},
{
"group": "campaigns",
"permissions":
[
"campaigns:get",
"campaigns:get_analytics",
"campaigns:manage"
]
},
{
"group": "bounces",
"permissions":
[
"bounces:get",
"bounces:manage",
"webhooks:post_bounce"
]
},
{
"group": "media",
"permissions":
[
"media:get",
"media:manage"
]
},
{
"group": "templates",
"permissions":
[
"templates:get",
"templates:manage"
]
},
{
"group": "users",
"permissions":
[
"users:get",
"users:manage",
"roles:get",
"roles:manage"
]
},
{
"group": "settings",
"permissions":
[
"settings:get",
"settings:manage",
"settings:maintain"
]
}
]

View file

@ -1,4 +1,3 @@
-- subscribers
-- name: get-subscriber
-- Get a single subscriber by id or UUID or email.
@ -9,6 +8,16 @@ SELECT * FROM subscribers WHERE
WHEN $3 != '' THEN email = $3
END;
-- name: has-subscriber-list
-- Used for checking access permission by list.
SELECT s.id AS subscriber_id,
CASE
WHEN EXISTS (SELECT 1 FROM subscriber_lists sl WHERE sl.subscriber_id = s.id AND sl.list_id = ANY($2))
THEN TRUE
ELSE FALSE
END AS has
FROM subscribers s WHERE s.id = ANY($1);
-- name: get-subscribers-by-emails
-- Get subscribers by emails.
SELECT * FROM subscribers WHERE email=ANY($1);
@ -179,7 +188,6 @@ INSERT INTO subscriber_lists (subscriber_id, list_id, status)
-- When subscriber is edited from the admin form, retain the status. Otherwise, a blocklisted
-- subscriber when being re-enabled, their subscription statuses change.
WHEN subscriber_lists.status = 'confirmed' THEN 'confirmed'
WHEN $9 = TRUE THEN subscriber_lists.status
ELSE $8::subscription_status
END
);
@ -413,6 +421,10 @@ UPDATE subscriber_lists SET status='unsubscribed', updated_at=NOW()
-- lists
-- name: get-lists
SELECT * FROM lists WHERE (CASE WHEN $1 = '' THEN 1=1 ELSE type=$1::list_type END)
AND CASE
-- Optional list IDs based on user permission.
WHEN $3 = TRUE THEN TRUE ELSE id = ANY($4::INT[])
END
ORDER BY CASE WHEN $2 = 'id' THEN id END, CASE WHEN $2 = 'name' THEN name END;
-- name: query-lists
@ -427,7 +439,11 @@ WITH ls AS (
AND ($4 = '' OR type = $4::list_type)
AND ($5 = '' OR optin = $5::list_optin)
AND (CARDINALITY($6::VARCHAR(100)[]) = 0 OR $6 <@ tags)
OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END)
AND CASE
-- Optional list IDs based on user permission.
WHEN $7 = TRUE THEN TRUE ELSE id = ANY($8::INT[])
END
OFFSET $9 LIMIT (CASE WHEN $10 < 1 THEN NULL ELSE $10 END)
),
statuses AS (
SELECT
@ -873,28 +889,6 @@ WITH view AS (
INSERT INTO campaign_views (campaign_id, subscriber_id)
VALUES((SELECT campaign_id FROM view), (SELECT subscriber_id FROM view));
-- users
-- name: get-users
SELECT * FROM users WHERE $1 = 0 OR id = $1 OFFSET $2 LIMIT $3;
-- name: create-user
INSERT INTO users (email, name, password, type, status) VALUES($1, $2, $3, $4, $5) RETURNING id;
-- name: update-user
UPDATE users SET
email=(CASE WHEN $2 != '' THEN $2 ELSE email END),
name=(CASE WHEN $3 != '' THEN $3 ELSE name END),
password=(CASE WHEN $4 != '' THEN $4 ELSE password END),
type=(CASE WHEN $5 != '' THEN $5::user_type ELSE type END),
status=(CASE WHEN $6 != '' THEN $6::user_status ELSE status END),
updated_at=NOW()
WHERE id = $1;
-- name: delete-user
-- Delete a user, except for the primordial super admin.
DELETE FROM users WHERE $1 != 1 AND id=$1;
-- templates
-- name: get-templates
-- Only if the second param ($2) is true, body is returned.
@ -1048,3 +1042,171 @@ DELETE FROM bounces WHERE subscriber_id = (SELECT id FROM sub);
-- name: get-db-info
SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),
'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info;
-- name: create-user
INSERT INTO users (username, password_login, password, email, name, type, user_role_id, list_role_id, status)
VALUES($1, $2, (
CASE
-- For user types with password_login enabled, bcrypt and store the hash of the password.
WHEN $6::user_type != 'api' AND $2 AND $3 != ''
THEN CRYPT($3, GEN_SALT('bf'))
WHEN $6 = 'api'
-- For APIs, store the password (token) as-is.
THEN $3
ELSE NULL
END
), $4, $5, $6, (SELECT id FROM roles WHERE id = $7 AND type = 'user'), (SELECT id FROM roles WHERE id = $8 AND type = 'list'), $9) RETURNING id;
-- name: update-user
WITH u AS (
-- Edit is only allowed if there are more than 1 active super users or
-- if the only superadmin user's status/role isn't being changed.
SELECT
CASE
WHEN (SELECT COUNT(*) FROM users WHERE id != $1 AND status = 'enabled' AND type = 'user' AND user_role_id = 1) = 0 AND ($8 != 1 OR $10 != 'enabled')
THEN FALSE
ELSE TRUE
END AS canEdit
)
UPDATE users SET
username=(CASE WHEN $2 != '' THEN $2 ELSE username END),
password_login=$3,
password=(CASE WHEN $3 = TRUE THEN (CASE WHEN $4 != '' THEN CRYPT($4, GEN_SALT('bf')) ELSE password END) ELSE NULL END),
email=(CASE WHEN $5 != '' THEN $5 ELSE email END),
name=(CASE WHEN $6 != '' THEN $6 ELSE name END),
type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END),
user_role_id=(CASE WHEN $8 != 0 THEN (SELECT id FROM roles WHERE id = $8 AND type = 'user') ELSE user_role_id END),
list_role_id=(
CASE
WHEN $9 < 0 THEN NULL
WHEN $9 > 0 THEN (SELECT id FROM roles WHERE id = $9 AND type = 'list')
ELSE list_role_id END
),
status=(CASE WHEN $10 != '' THEN $10::user_status ELSE status END),
updated_at=NOW()
WHERE id=$1 AND (SELECT canEdit FROM u) = TRUE;
-- name: delete-users
WITH u AS (
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND user_role_id=1 AND status='enabled'
)
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
-- name: get-users
WITH ur AS (
SELECT id, name, permissions FROM roles WHERE type = 'user' AND parent_id IS NULL
),
lr AS (
SELECT r.id, r.name, r.permissions, r.list_id, l.name AS list_name
FROM roles r
LEFT JOIN lists l ON r.list_id = l.id
WHERE r.type = 'list' AND r.parent_id IS NULL
),
lp AS (
SELECT lr.id AS list_role_id,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', COALESCE(cr.list_id, lr.list_id),
'name', COALESCE(cl.name, lr.list_name),
'permissions', COALESCE(cr.permissions, lr.permissions)
)
) AS list_role_perms
FROM lr
LEFT JOIN roles cr ON cr.parent_id = lr.id AND cr.type = 'list'
LEFT JOIN lists cl ON cr.list_id = cl.id
GROUP BY lr.id
)
SELECT
users.*,
ur.id AS user_role_id,
ur.name AS user_role_name,
ur.permissions AS user_role_permissions,
lp.list_role_id,
lr.name AS list_role_name,
lp.list_role_perms
FROM users
LEFT JOIN ur ON users.user_role_id = ur.id
LEFT JOIN lp ON users.list_role_id = lp.list_role_id
LEFT JOIN lr ON lp.list_role_id = lr.id
WHERE
(
CASE
WHEN $1::INT != 0 THEN users.id = $1
WHEN $2::TEXT != '' THEN username = $2
WHEN $3::TEXT != '' THEN email = $3
ELSE TRUE
END
)
ORDER BY users.created_at;
-- name: get-api-tokens
SELECT username, password FROM users WHERE status='enabled' AND type='api';
-- name: login-user
WITH u AS (
SELECT users.*, r.name as role_name, r.permissions FROM users
LEFT JOIN roles r ON (r.id = users.user_role_id)
WHERE username=$1 AND status != 'disabled' AND password_login = TRUE
)
SELECT * FROM u WHERE CRYPT($2, password) = password;
-- name: update-user-profile
UPDATE users SET name=$2, email=(CASE WHEN password_login THEN $3 ELSE email END),
password=(CASE WHEN $4 = TRUE THEN (CASE WHEN $5 != '' THEN CRYPT($5, GEN_SALT('bf')) ELSE password END) ELSE NULL END)
WHERE id=$1;
-- name: update-user-login
UPDATE users SET loggedin_at=NOW(), avatar=(CASE WHEN $2 != '' THEN $2 ELSE avatar END) WHERE id=$1;
-- name: get-user-roles
WITH mainroles AS (
SELECT ur.* FROM roles ur WHERE type = 'user' AND ur.parent_id IS NULL
),
listPerms AS (
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
FROM roles ur
LEFT JOIN lists ON(lists.id = ur.list_id)
WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
)
SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
-- name: get-list-roles
WITH mainroles AS (
SELECT ur.* FROM roles ur WHERE type = 'list' AND ur.parent_id IS NULL
),
listPerms AS (
SELECT ur.parent_id, JSONB_AGG(JSONB_BUILD_OBJECT('id', ur.list_id, 'name', lists.name, 'permissions', ur.permissions)) AS listPerms
FROM roles ur
LEFT JOIN lists ON(lists.id = ur.list_id)
WHERE ur.parent_id IS NOT NULL GROUP BY ur.parent_id
)
SELECT p.*, COALESCE(l.listPerms, '[]'::JSONB) AS "list_permissions" FROM mainroles p
LEFT JOIN listPerms l ON p.id = l.parent_id ORDER BY p.created_at;
-- name: create-role
INSERT INTO roles (name, type, permissions, created_at, updated_at) VALUES($1, $2, $3, NOW(), NOW()) RETURNING *;
-- name: upsert-list-permissions
WITH d AS (
-- Delete lists that aren't included.
DELETE FROM roles WHERE parent_id = $1 AND list_id != ALL($2::INT[])
),
p AS (
-- Get (list_id, perms[]), (list_id, perms[])
SELECT UNNEST($2) AS list_id, JSONB_ARRAY_ELEMENTS(TO_JSONB($3::TEXT[][])) AS perms
)
INSERT INTO roles (parent_id, list_id, permissions, type)
SELECT $1, list_id, ARRAY_REMOVE(ARRAY(SELECT JSONB_ARRAY_ELEMENTS_TEXT(perms)), ''), 'list' FROM p
ON CONFLICT (parent_id, list_id) DO UPDATE SET permissions = EXCLUDED.permissions;
-- name: delete-list-permission
DELETE FROM roles WHERE parent_id=$1 AND list_id=$2;
-- name: update-role
UPDATE roles SET name=$2, permissions=$3 WHERE id=$1 and parent_id IS NULL RETURNING *;
-- name: delete-role
DELETE FROM roles WHERE id=$1;

View file

@ -7,6 +7,11 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'api');
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
DROP TYPE IF EXISTS role_type CASCADE; CREATE TYPE role_type AS ENUM ('user', 'list');
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- subscribers
DROP TABLE IF EXISTS subscribers CASCADE;
@ -246,6 +251,7 @@ INSERT INTO settings (key, value) VALUES
('security.enable_captcha', 'false'),
('security.captcha_key', '""'),
('security.captcha_secret', '""'),
('security.oidc', '{"enabled": false, "provider_url": "", "client_id": "", "client_secret": ""}'),
('upload.provider', '"filesystem"'),
('upload.max_file_size', '5000'),
('upload.extensions', '["jpg","jpeg","png","gif","svg","*"]'),
@ -295,7 +301,48 @@ DROP INDEX IF EXISTS idx_bounces_camp_id; CREATE INDEX idx_bounces_camp_id ON bo
DROP INDEX IF EXISTS idx_bounces_source; CREATE INDEX idx_bounces_source ON bounces(source);
DROP INDEX IF EXISTS idx_bounces_date; CREATE INDEX idx_bounces_date ON bounces((TIMEZONE('UTC', created_at)::DATE));
-- roles
DROP TABLE IF EXISTS roles CASCADE;
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
type role_type NOT NULL DEFAULT 'user',
parent_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE ON UPDATE CASCADE,
list_id INTEGER NULL REFERENCES lists(id) ON DELETE CASCADE ON UPDATE CASCADE,
permissions TEXT[] NOT NULL DEFAULT '{}',
name TEXT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX roles_idx ON roles (parent_id, list_id);
CREATE UNIQUE INDEX roles_name_idx ON roles (type, name) WHERE name IS NOT NULL;
-- users
DROP TABLE IF EXISTS users CASCADE;
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_login BOOLEAN NOT NULL DEFAULT false,
password TEXT NULL,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
avatar TEXT NULL,
type user_type NOT NULL DEFAULT 'user',
user_role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE RESTRICT,
list_role_id INTEGER NULL REFERENCES roles(id) ON DELETE CASCADE,
status user_status NOT NULL DEFAULT 'disabled',
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- user sessions
DROP TABLE IF EXISTS sessions CASCADE;
CREATE TABLE sessions (
id TEXT NOT NULL PRIMARY KEY,
data JSONB DEFAULT '{}'::jsonb NOT NULL,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT now() NOT NULL
);
DROP INDEX IF EXISTS idx_sessions; CREATE INDEX idx_sessions ON sessions (id, created_at);
-- materialized views

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -34,7 +34,7 @@ h4 {
margin-bottom: 45px;
}
input[type="text"], input[type="email"], select {
input[type="text"], input[type="email"], input[type="password"], select {
padding: 10px 15px;
border: 1px solid #888;
border-radius: 3px;
@ -61,6 +61,9 @@ input[disabled] {
.right {
text-align: right;
}
.error {
color: #FF5722;
}
.button {
background: #0055d4;
padding: 15px 30px;
@ -84,7 +87,8 @@ input[disabled] {
color: #0055d4;
}
.button.button-outline:hover {
background-color: #0055d4;
border-color: #333;
background-color: #333;
color: #fff;
}
@ -178,6 +182,21 @@ input[disabled] {
font-weight: bold;
}
.login .submit {
margin-top: 20px;
}
.login button {
width: 100%;
vertical-align: middle;
display: flex;
align-items: center;
justify-content: center;
}
.login button img {
max-width: 24px;
margin-right: 10px;
}
#btn-back {
display: none;
}

View file

@ -0,0 +1,43 @@
{{ define "admin-login" }}
{{ template "header" .}}
<section class="login">
<h2>{{ .L.T "users.login"}}</h2>
{{ if .Data.PasswordEnabled }}
<form method="post" action="{{ .RootURL }}/admin/login" class="form">
<div>
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
<input type="hidden" name="next" value="{{ .Data.NextURI }}" />
<p>
<label for="username">{{ .L.T "users.username" }}</label>
<input id="username" type="text" name="username" autofocus required minlength="3" />
</p>
<p>
<label for="password">{{ .L.T "users.password" }}</label>
<input id="password" type="password" name="password" required minlength="8" />
</p>
{{ if .Data.Error }}<p><span class="error">{{ .Data.Error }}</span></p>{{ end }}
<p class="submit"><button class="button" type="submit">{{ .L.T "users.login" }}</button></p>
</div>
</form>
{{ end }}
{{ if .Data.OIDCProvider }}
<form method="post" action="{{ .RootURL }}/auth/oidc">
<div>
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
<input type="hidden" name="next" value="{{ .Data.NextURI }}" />
<p><button class="button button-outline" type="submit">
<img src="{{ .RootURL }}/public/static/auth/{{ .Data.OIDCProviderLogo }}" alt="" />
{{ .L.Ts "users.loginOIDC" "name" .Data.OIDCProvider }}
</button></p>
</div>
</form>
{{ end }}
</section>
{{ template "footer" .}}
{{ end }}