diff --git a/cmd/lists.go b/cmd/lists.go index 7a8d34e7..90409067 100644 --- a/cmd/lists.go +++ b/cmd/lists.go @@ -1,19 +1,26 @@ package main import ( + "fmt" "net/http" "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. +var ( + errListPerm = echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("list permission denied")) +) + +// 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 +29,13 @@ 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}) - } - // 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("", user.GetListIDs) if err != nil { return err } @@ -61,20 +53,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, user.GetListIDs, 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 +67,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 +158,36 @@ 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 ( + u = c.Get(auth.UserKey).(models.User) + id, _ = strconv.Atoi(c.Param("id")) + ) + + // Define permissions based on HTTP read/write. + var ( + permAll = "lists:manage_all" + perm = "list:manage" + ) + if c.Request().Method == http.MethodGet { + permAll = "lists:get_all" + perm = "list:get" + } + + // Check if the user has permissions for all lists or the specific list. + if _, ok := u.PermissionsMap[permAll]; ok { + return next(c) + } + if id > 0 { + if _, ok := u.ListPermissionsMap[id][perm]; ok { + return next(c) + } + } + + return errListPerm + } +} diff --git a/cmd/public.go b/cmd/public.go index 7216cb8b..79654093 100644 --- a/cmd/public.go +++ b/cmd/public.go @@ -115,7 +115,7 @@ func handleGetPublicLists(c echo.Context) error { ) // Get all public lists. - lists, err := app.core.GetLists(models.ListTypePublic) + lists, err := app.core.GetLists(models.ListTypePublic, 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, nil) if err != nil { return c.Render(http.StatusInternalServerError, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists"))) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c06a7c8b..2eca5d16 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -200,9 +200,7 @@ export default Vue.extend({ mounted() { // Lists is required across different views. On app load, fetch the lists // and have them in the store. - if (this.$can('lists:get_all')) { - this.$api.getLists({ minimal: true, per_page: 'all' }); - } + this.$api.getLists({ minimal: true, per_page: 'all' }); window.addEventListener('resize', () => { this.windowWidth = window.innerWidth; diff --git a/frontend/src/components/Navigation.vue b/frontend/src/components/Navigation.vue index 5f1efd33..89e799b2 100644 --- a/frontend/src/components/Navigation.vue +++ b/frontend/src/components/Navigation.vue @@ -3,7 +3,7 @@ - { + Vue.prototype.$can = (...perms) => { if (profile.role_id === 1) { return true; } @@ -52,12 +52,14 @@ async function initConfig(app) { // 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. - if (perm.endsWith('*')) { - const group = `${perm.split(':')[0]}:`; - return profile.role.permissions.some((p) => p.startsWith(group)); - } + return perms.some((perm) => { + if (perm.endsWith('*')) { + const group = `${perm.split(':')[0]}:`; + return profile.role.permissions.some((p) => p.startsWith(group)); + } - return profile.role.permissions.includes(perm); + return profile.role.permissions.includes(perm); + }); }; // Set the page title after i18n has loaded. diff --git a/frontend/src/views/ListForm.vue b/frontend/src/views/ListForm.vue index a034eb46..e0002883 100644 --- a/frontend/src/views/ListForm.vue +++ b/frontend/src/views/ListForm.vue @@ -58,8 +58,7 @@ {{ $t('globals.buttons.close') }} - + {{ $t('globals.buttons.save') }} @@ -124,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.role.lists.find((l) => l.id === this.$props.data.id); + return list && list.permissions.includes('list:manage'); + }, }, mounted() { diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9b162902..3c4a8965 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -23,9 +23,8 @@ import ( const ( // UserKey is the key on which the User profile is set on echo handlers. - UserKey = "auth_user" - SessionKey = "auth_session" - + UserKey = "auth_user" + SessionKey = "auth_session" SuperAdminRoleID = 1 ) @@ -259,7 +258,7 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { } } -func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc { +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 { @@ -273,8 +272,19 @@ func (o *Auth) Perm(next echo.HandlerFunc, perm string) echo.HandlerFunc { } // Check if the current handler's permission is in the user's permission map. - if _, ok := u.PermissionsMap[perm]; !ok { - return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied (%s)", perm)) + 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) diff --git a/internal/core/lists.go b/internal/core/lists.go index 1db7f111..e2fabbf1 100644 --- a/internal/core/lists.go +++ b/internal/core/lists.go @@ -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, 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", 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, 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), 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{}, 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))) diff --git a/internal/core/users.go b/internal/core/users.go index a31ea110..9b1f0720 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -184,6 +184,14 @@ func (c *Core) getUsers(id int, username, email string) ([]models.User, error) { 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) + } } } diff --git a/models/models.go b/models/models.go index 83fc1922..0938dadc 100644 --- a/models/models.go +++ b/models/models.go @@ -175,6 +175,8 @@ type User struct { 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:"-"` } diff --git a/permissions.json b/permissions.json index 52eecfbd..235a9358 100644 --- a/permissions.json +++ b/permissions.json @@ -11,6 +11,7 @@ "group": "subscribers", "permissions": [ + "subscribers:get_by_list", "subscribers:get", "subscribers:manage", "subscribers:import", diff --git a/queries.sql b/queries.sql index 5c6bb61f..b6e6c17b 100644 --- a/queries.sql +++ b/queries.sql @@ -1,4 +1,3 @@ - -- subscribers -- name: get-subscriber -- Get a single subscriber by id or UUID or email. @@ -413,6 +412,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::INT[] IS NULL THEN TRUE ELSE id = ANY($3) + END ORDER BY CASE WHEN $2 = 'id' THEN id END, CASE WHEN $2 = 'name' THEN name END; -- name: query-lists @@ -427,7 +430,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::INT[] IS NULL THEN TRUE ELSE id = ANY($7) + END + OFFSET $8 LIMIT (CASE WHEN $9 < 1 THEN NULL ELSE $9 END) ), statuses AS ( SELECT