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 @@
- toggleGroup('lists', state)" icon="format-list-bulleted-square"
:label="$t('globals.terms.lists')">
{
+ 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