mirror of
https://github.com/knadh/listmonk.git
synced 2025-09-12 09:25:38 +08:00
Introduce per-campaign filter permissions. Closes #2325.
This patch introduces new `campaigns:get_all` and `campaigns:manage_all` permissions which alter the behaviour of the the old `campaigns:get` and `campaigns:manage` permissions. This is a subtle breaking behavioural change. Old: - `campaigns:get` -> View all campaigns irrespective of a user's list permissions. - `campaigns:manage` -> Manage all campaigns irrespective of a user's list permissions. New: - `campaigns:get_all` -> View all campaigns irrespective of a user's list permissions. - `campaigns:manage_all` -> Manage all campaigns irrespective of a user's list permissions. - `campaigns:get` -> View only the campaigns that have at least one list to which which a user has get or manage access. - `campaigns:manage` -> Manage only the campaigns that have at list one list to which a user has get or manage access. In addition, this patch refactors and cleans up certain permission related logic and functions.
This commit is contained in:
parent
a5f8b28cb1
commit
a271bf54d5
16 changed files with 394 additions and 191 deletions
129
cmd/campaigns.go
129
cmd/campaigns.go
|
@ -13,6 +13,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/internal/auth"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
|
@ -53,6 +54,7 @@ var (
|
|||
func handleGetCampaigns(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
pg = app.paginator.NewFromURL(c.Request().URL.Query())
|
||||
|
||||
status = c.QueryParams()["status"]
|
||||
|
@ -63,17 +65,31 @@ func handleGetCampaigns(c echo.Context) error {
|
|||
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
|
||||
)
|
||||
|
||||
res, total, err := app.core.QueryCampaigns(query, status, tags, orderBy, order, pg.Offset, pg.Limit)
|
||||
var (
|
||||
hasAllPerm = user.HasPerm(models.PermCampaignsGetAll)
|
||||
permittedLists []int
|
||||
)
|
||||
|
||||
if !hasAllPerm {
|
||||
// Either the user has campaigns:get_all permissions and can view all campaigns,
|
||||
// or the campaigns are filtered by the lists the user has get|manage access to.
|
||||
hasAllPerm, permittedLists = user.GetPermittedLists(true, true)
|
||||
}
|
||||
|
||||
// Query and retrieve the campaigns.
|
||||
res, total, err := app.core.QueryCampaigns(query, status, tags, orderBy, order, hasAllPerm, permittedLists, pg.Offset, pg.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove the body from the response if requested.
|
||||
if noBody {
|
||||
for i := 0; i < len(res); i++ {
|
||||
for i := range res {
|
||||
res[i].Body = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate the response.
|
||||
var out models.PageResults
|
||||
if len(res) == 0 {
|
||||
out.Results = []models.Campaign{}
|
||||
|
@ -94,10 +110,21 @@ func handleGetCampaigns(c echo.Context) error {
|
|||
func handleGetCampaign(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
noBody, _ = strconv.ParseBool(c.QueryParam("no_body"))
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, true, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the campaign from the DB.
|
||||
out, err := app.core.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -114,6 +141,7 @@ func handleGetCampaign(c echo.Context) error {
|
|||
func handlePreviewCampaign(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
|
||||
)
|
||||
|
@ -122,6 +150,12 @@ func handlePreviewCampaign(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, true, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch the campaign body from the DB.
|
||||
camp, err := app.core.GetCampaignForPreview(id, tplID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -243,6 +277,12 @@ func handleUpdateCampaign(c echo.Context) error {
|
|||
|
||||
}
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, false, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Retrieve the campaign from the DB.
|
||||
cm, err := app.core.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -285,20 +325,26 @@ func handleUpdateCampaignStatus(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
var o struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&o); err != nil {
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, false, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateCampaignStatus(id, o.Status)
|
||||
req := struct {
|
||||
Status string `json:"status"`
|
||||
}{}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the campaign status in the DB.
|
||||
out, err := app.core.UpdateCampaignStatus(id, req.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if o.Status == models.CampaignStatusPaused || o.Status == models.CampaignStatusCancelled {
|
||||
// If the campaign is being stopped, send the signal to the manager to stop it in flight.
|
||||
if req.Status == models.CampaignStatusPaused || req.Status == models.CampaignStatusCancelled {
|
||||
app.manager.StopCampaign(id)
|
||||
}
|
||||
|
||||
|
@ -312,14 +358,17 @@ func handleUpdateCampaignArchive(c echo.Context) error {
|
|||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, false, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := struct {
|
||||
Archive bool `json:"archive"`
|
||||
TemplateID int `json:"archive_template_id"`
|
||||
Meta models.JSON `json:"archive_meta"`
|
||||
ArchiveSlug string `json:"archive_slug"`
|
||||
}{}
|
||||
|
||||
// Get and validate fields.
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -351,6 +400,12 @@ func handleDeleteCampaign(c echo.Context) error {
|
|||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
|
||||
}
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, false, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the campaign from the DB.
|
||||
if err := app.core.DeleteCampaign(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -402,16 +457,22 @@ func handleGetRunningCampaignStats(c echo.Context) error {
|
|||
func handleTestCampaign(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
campID, _ = strconv.Atoi(c.Param("id"))
|
||||
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
tplID, _ = strconv.Atoi(c.FormValue("template_id"))
|
||||
req campaignReq
|
||||
)
|
||||
|
||||
if campID < 1 {
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
|
||||
}
|
||||
|
||||
// Check if the user has access to the campaign.
|
||||
if err := checkCampaignPerm(id, false, c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get and validate fields.
|
||||
var req campaignReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -437,7 +498,7 @@ func handleTestCampaign(c echo.Context) error {
|
|||
}
|
||||
|
||||
// The campaign.
|
||||
camp, err := app.core.GetCampaignForPreview(campID, tplID)
|
||||
camp, err := app.core.GetCampaignForPreview(id, tplID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -644,3 +705,41 @@ func makeOptinCampaignMessage(o campaignReq, app *App) (campaignReq, error) {
|
|||
o.Body = b.String()
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// checkCampaignPerm checks if the user has get or manage access to the given campaign.
|
||||
func checkCampaignPerm(id int, isGet bool, c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
user = c.Get(auth.UserKey).(models.User)
|
||||
)
|
||||
|
||||
perm := models.PermCampaignsGet
|
||||
if isGet {
|
||||
// It's a get request and there's a blanket get all permission.
|
||||
if user.HasPerm(models.PermCampaignsGetAll) {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// It's a manage request and there's a blanket manage_all permission.
|
||||
if user.HasPerm(models.PermCampaignsManageAll) {
|
||||
return nil
|
||||
}
|
||||
|
||||
perm = models.PermCampaignsManage
|
||||
}
|
||||
|
||||
// There are no *_all campaign permissions. Instead, check if the user access
|
||||
// blanket get_all/manage_all list permissions. If yes, then the user can access
|
||||
// all campaigns. If there are no *_all permissions, then ensure that the
|
||||
// campaign belongs to the lists that the user has access to.
|
||||
if hasAllPerm, permittedListIDs := user.GetPermittedLists(true, true); !hasAllPerm {
|
||||
if ok, err := app.core.CampaignHasLists(id, permittedListIDs); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return echo.NewHTTPError(http.StatusForbidden,
|
||||
app.i18n.Ts("globals.messages.permissionDenied", "name", perm))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -138,20 +138,20 @@ func initHTTPHandlers(e *echo.Echo, app *App) {
|
|||
api.PUT("/api/lists/:id", listPerm(handleUpdateList))
|
||||
api.DELETE("/api/lists/:id", listPerm(handleDeleteLists))
|
||||
|
||||
api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get"))
|
||||
api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get"))
|
||||
api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get"))
|
||||
api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get_all", "campaigns:get"))
|
||||
api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get_all", "campaigns:get"))
|
||||
api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get_all", "campaigns:get"))
|
||||
api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics"))
|
||||
api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage"))
|
||||
api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage"))
|
||||
api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage"))
|
||||
api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage"))
|
||||
api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage"))
|
||||
api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get_all", "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get_all", "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:get"))
|
||||
api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage_all", "campaigns:manage"))
|
||||
api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage_all", "campaigns:manage"))
|
||||
|
||||
api.GET("/api/media", pm(handleGetMedia, "media:get"))
|
||||
api.GET("/api/media/:id", pm(handleGetMedia, "media:get"))
|
||||
|
|
|
@ -1013,7 +1013,7 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) (bool, *auth.Auth) {
|
|||
Status: models.UserStatusEnabled,
|
||||
Type: models.UserTypeAPI,
|
||||
}
|
||||
u.UserRole.ID = auth.SuperAdminRoleID
|
||||
u.UserRole.ID = models.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.`)
|
||||
|
|
23
cmd/lists.go
23
cmd/lists.go
|
@ -28,19 +28,12 @@ func handleGetLists(c echo.Context) error {
|
|||
out models.PageResults
|
||||
)
|
||||
|
||||
var (
|
||||
permittedIDs []int
|
||||
getAll = false
|
||||
)
|
||||
if _, ok := user.PermissionsMap[models.PermListGetAll]; ok {
|
||||
getAll = true
|
||||
} else {
|
||||
permittedIDs = user.GetListIDs
|
||||
}
|
||||
// Get the list IDs (or blanket permission) the user has access to.
|
||||
hasAllPerm, permittedIDs := user.GetPermittedLists(true, false)
|
||||
|
||||
// Minimal query simply returns the list of all lists without JOIN subscriber counts. This is fast.
|
||||
if minimal {
|
||||
res, err := app.core.GetLists("", getAll, permittedIDs)
|
||||
res, err := app.core.GetLists("", hasAllPerm, permittedIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -58,7 +51,7 @@ func handleGetLists(c echo.Context) error {
|
|||
}
|
||||
|
||||
// Full list query.
|
||||
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, getAll, permittedIDs, pg.Offset, pg.Limit)
|
||||
res, total, err := app.core.QueryLists(query, typ, optin, tags, orderBy, order, hasAllPerm, permittedIDs, pg.Offset, pg.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -73,6 +66,7 @@ func handleGetLists(c echo.Context) error {
|
|||
}
|
||||
|
||||
// handleGetList retrieves a single list by id.
|
||||
// It's permission checked by the listPerm middleware.
|
||||
func handleGetList(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
@ -112,6 +106,7 @@ func handleCreateList(c echo.Context) error {
|
|||
}
|
||||
|
||||
// handleUpdateList handles list modification.
|
||||
// It's permission checked by the listPerm middleware.
|
||||
func handleUpdateList(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
@ -142,6 +137,7 @@ func handleUpdateList(c echo.Context) error {
|
|||
}
|
||||
|
||||
// handleDeleteLists handles list deletion, either a single one (ID in the URI), or a list.
|
||||
// It's permission checked by the listPerm middleware.
|
||||
func handleDeleteLists(c echo.Context) error {
|
||||
var (
|
||||
app = c.Get("app").(*App)
|
||||
|
@ -185,11 +181,12 @@ func listPerm(next echo.HandlerFunc) echo.HandlerFunc {
|
|||
}
|
||||
|
||||
// Check if the user has permissions for all lists or the specific list.
|
||||
if _, ok := user.PermissionsMap[permAll]; ok {
|
||||
if user.HasPerm(permAll) {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if id > 0 {
|
||||
if _, ok := user.ListPermissionsMap[id][perm]; ok {
|
||||
if user.HasListPerm(id, perm) {
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -672,7 +672,7 @@ func sendOptinConfirmationHook(app *App) func(sub models.Subscriber, listIDs []i
|
|||
// 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 {
|
||||
if u.UserRoleID == models.SuperAdminRoleID {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@
|
|||
</div>
|
||||
|
||||
<div class="column is-6">
|
||||
<div v-if="$can('campaigns:manage')" class="buttons">
|
||||
<div v-if="canManage" class="buttons">
|
||||
<b-field grouped v-if="isEditing && canEdit">
|
||||
<b-field expanded>
|
||||
<b-button expanded @click="() => onSubmit('update')" :loading="loading.campaigns" type="is-primary"
|
||||
|
@ -157,7 +157,7 @@
|
|||
</b-field>
|
||||
</form>
|
||||
</div>
|
||||
<div v-if="$can('campaigns:manage')" class="column is-4 is-offset-1">
|
||||
<div v-if="canManage" class="column is-4 is-offset-1">
|
||||
<br />
|
||||
<div class="box">
|
||||
<h3 class="title is-size-6">
|
||||
|
@ -620,6 +620,10 @@ export default Vue.extend({
|
|||
computed: {
|
||||
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
|
||||
|
||||
canManage() {
|
||||
return this.$can('campaigns:manage_all', 'campaigns:manage');
|
||||
},
|
||||
|
||||
canEdit() {
|
||||
return this.isNew
|
||||
|| this.data.status === 'draft' || this.data.status === 'scheduled' || this.data.status === 'paused';
|
||||
|
|
|
@ -25,7 +25,6 @@ const (
|
|||
// UserKey is the key on which the User profile is set on echo handlers.
|
||||
UserKey = "auth_user"
|
||||
SessionKey = "auth_session"
|
||||
SuperAdminRoleID = 1
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -282,7 +281,7 @@ func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
|
|||
}
|
||||
|
||||
// If the current user is a Super Admin user, do no checks.
|
||||
if u.UserRole.ID == SuperAdminRoleID {
|
||||
if u.UserRole.ID == models.SuperAdminRoleID {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ const (
|
|||
|
||||
// QueryCampaigns retrieves paginated campaigns optionally filtering them by the given arbitrary
|
||||
// query expression. It also returns the total number of records in the DB.
|
||||
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, offset, limit int) (models.Campaigns, int, error) {
|
||||
func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy, order string, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
|
||||
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
|
||||
|
||||
if statuses == nil {
|
||||
|
@ -36,7 +36,7 @@ func (c *Core) QueryCampaigns(searchStr string, statuses, tags []string, orderBy
|
|||
|
||||
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
||||
var out models.Campaigns
|
||||
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, offset, limit); err != nil {
|
||||
if err := c.db.Select(&out, stmt, 0, pq.StringArray(statuses), pq.StringArray(tags), queryStr, getAll, pq.Array(permittedLists), offset, limit); err != nil {
|
||||
c.log.Printf("error fetching campaigns: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
|
@ -327,6 +327,18 @@ func (c *Core) DeleteCampaign(id int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// CampaignHasLists checks if a campaign has any of the given list IDs.
|
||||
func (c *Core) CampaignHasLists(id int, listIDs []int) (bool, error) {
|
||||
has := false
|
||||
if err := c.q.CampaignHasLists.Get(&has, id, pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error checking campaign lists: %v", err)
|
||||
return false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return has, nil
|
||||
}
|
||||
|
||||
// GetRunningCampaignStats returns the progress stats of running campaigns.
|
||||
func (c *Core) GetRunningCampaignStats() ([]models.CampaignStats, error) {
|
||||
out := []models.CampaignStats{}
|
||||
|
|
|
@ -19,7 +19,7 @@ func (c *Core) GetUsers() ([]models.User, error) {
|
|||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.formatUsers(out), nil
|
||||
return c.setupUserFields(out), nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a specific user based on any one given identifier.
|
||||
|
@ -36,7 +36,7 @@ func (c *Core) GetUser(id int, username, email string) (models.User, error) {
|
|||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.formatUsers([]models.User{out})[0], nil
|
||||
return c.setupUserFields([]models.User{out})[0], nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
|
@ -152,7 +152,8 @@ func (c *Core) LoginUser(username, password string) (models.User, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Core) formatUsers(users []models.User) []models.User {
|
||||
// setupUserFields prepares and sets up various user fields.
|
||||
func (c *Core) setupUserFields(users []models.User) []models.User {
|
||||
for n, u := range users {
|
||||
u := u
|
||||
|
||||
|
@ -188,6 +189,7 @@ func (c *Core) formatUsers(users []models.User) []models.User {
|
|||
|
||||
u.ListRole = &models.ListRolePermissions{ID: *u.ListRoleID, Name: u.ListRoleName.String, Lists: listPerms}
|
||||
|
||||
// Iterate each list in the list permissions and setup get/manage list IDs.
|
||||
for _, p := range listPerms {
|
||||
u.ListPermissionsMap[p.ID] = make(map[string]struct{})
|
||||
|
||||
|
@ -195,10 +197,10 @@ func (c *Core) formatUsers(users []models.User) []models.User {
|
|||
u.ListPermissionsMap[p.ID][perm] = struct{}{}
|
||||
|
||||
// List IDs with get / manage permissions.
|
||||
if perm == "list:get" {
|
||||
if perm == models.PermListGet {
|
||||
u.GetListIDs = append(u.GetListIDs, p.ID)
|
||||
}
|
||||
if perm == "list:manage" {
|
||||
if perm == models.PermListManage {
|
||||
u.ManageListIDs = append(u.ManageListIDs, p.ID)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
return err
|
||||
}
|
||||
|
||||
// Index of media filename lookup.
|
||||
if _, err := db.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_media_filename ON media(provider, filename);
|
||||
`); err != nil {
|
||||
|
@ -44,5 +45,13 @@ func V5_0_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
return err
|
||||
}
|
||||
|
||||
// Insert new default super admin permissions.
|
||||
if _, err := db.Exec(`
|
||||
UPDATE roles SET permissions = permissions || '{campaigns:get_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:get_all}';
|
||||
UPDATE roles SET permissions = permissions || '{campaigns:manage_all}' WHERE id = 1 AND NOT permissions @> '{campaigns:manage_all}';
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
124
models/models.go
124
models/models.go
|
@ -150,88 +150,6 @@ type Base struct {
|
|||
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// User represents an admin user.
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
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.
|
||||
type Subscriber struct {
|
||||
Base
|
||||
|
@ -816,45 +734,3 @@ 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
|
||||
}
|
||||
|
|
|
@ -12,8 +12,10 @@ const (
|
|||
PermSubscribersSqlQuery = "subscribers:sql_query"
|
||||
PermTxSend = "tx:send"
|
||||
PermCampaignsGet = "campaigns:get"
|
||||
PermCampaignsGetAll = "campaigns:get_all"
|
||||
PermCampaignsGetAnalytics = "campaigns:get_analytics"
|
||||
PermCampaignsManage = "campaigns:manage"
|
||||
PermCampaignsManageAll = "campaigns:manage_all"
|
||||
PermBouncesGet = "bounces:get"
|
||||
PermBouncesManage = "bounces:manage"
|
||||
PermWebhooksPostBounce = "webhooks:post_bounce"
|
||||
|
|
|
@ -65,6 +65,7 @@ type Queries struct {
|
|||
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
||||
GetCampaignStatus *sqlx.Stmt `query:"get-campaign-status"`
|
||||
GetArchivedCampaigns *sqlx.Stmt `query:"get-archived-campaigns"`
|
||||
CampaignHasLists *sqlx.Stmt `query:"campaign-has-lists"`
|
||||
|
||||
// These two queries are read as strings and based on settings.individual_tracking=on/off,
|
||||
// are interpolated and copied to view and click counts. Same query, different tables.
|
||||
|
|
190
models/users.go
Normal file
190
models/users.go
Normal file
|
@ -0,0 +1,190 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/lib/pq"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// SuperAdminRoleID is the database ID of the primordial super admin role.
|
||||
const SuperAdminRoleID = 1
|
||||
|
||||
// User represents an admin user.
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
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"`
|
||||
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:"-"`
|
||||
|
||||
// Non-DB fields 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// HasPerm checks if the user has a specific permission.
|
||||
func (u *User) HasPerm(perm string) bool {
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true
|
||||
}
|
||||
|
||||
_, ok := u.PermissionsMap[perm]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (u *User) HasListPerm(listID int, perm string) bool {
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, ok := u.ListPermissionsMap[listID]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := u.ListPermissionsMap[listID][perm]
|
||||
return ok
|
||||
}
|
||||
|
||||
// GetPermittedLists returns a list of IDs the user has access to based on
|
||||
// the given get / manage permissions. If the user has the blanket "*_all"
|
||||
// permission (or the user is a super admin), then the bool is set to true and
|
||||
// the list is nil as all lists are permitted.
|
||||
func (u *User) GetPermittedLists(get, manage bool) (bool, []int) {
|
||||
// Short-circuit if the user is the primordial super admin.
|
||||
if u.UserRoleID == SuperAdminRoleID {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If the user has the list:get_all or list:manage_all permission, no
|
||||
// further checks are required.
|
||||
if get {
|
||||
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
if manage {
|
||||
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
if get {
|
||||
// If the user has per-list permissions, return that. Otherwise, let the
|
||||
// 'manage' permission check run.
|
||||
if len(u.GetListIDs) > 0 {
|
||||
out := make([]int, len(u.GetListIDs))
|
||||
copy(out, u.GetListIDs)
|
||||
return false, out
|
||||
}
|
||||
}
|
||||
|
||||
if manage {
|
||||
// User has per-list permissions.
|
||||
out := make([]int, len(u.ManageListIDs))
|
||||
copy(out, u.ManageListIDs)
|
||||
return false, out
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
|
@ -24,8 +24,10 @@
|
|||
"permissions":
|
||||
[
|
||||
"campaigns:get",
|
||||
"campaigns:get_all",
|
||||
"campaigns:get_analytics",
|
||||
"campaigns:manage"
|
||||
"campaigns:manage",
|
||||
"campaigns:manage_all"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
18
queries.sql
18
queries.sql
|
@ -557,7 +557,13 @@ WHERE ($1 = 0 OR id = $1)
|
|||
AND (CARDINALITY($2::campaign_status[]) = 0 OR status = ANY($2))
|
||||
AND (CARDINALITY($3::VARCHAR(100)[]) = 0 OR $3 <@ tags)
|
||||
AND ($4 = '' OR TO_TSVECTOR(CONCAT(name, ' ', subject)) @@ TO_TSQUERY($4) OR CONCAT(c.name, ' ', c.subject) ILIKE $4)
|
||||
ORDER BY %order% OFFSET $5 LIMIT (CASE WHEN $6 < 1 THEN NULL ELSE $6 END);
|
||||
-- Get all campaigns or filter by list IDs.
|
||||
AND (
|
||||
$5 OR EXISTS (
|
||||
SELECT 1 FROM campaign_lists WHERE campaign_id = c.id AND list_id = ANY($6::INT[])
|
||||
)
|
||||
)
|
||||
ORDER BY %order% OFFSET $7 LIMIT (CASE WHEN $8 < 1 THEN NULL ELSE $8 END);
|
||||
|
||||
-- name: get-campaign
|
||||
SELECT campaigns.*,
|
||||
|
@ -640,9 +646,13 @@ LEFT JOIN templates ON (templates.id = (CASE WHEN $2=0 THEN campaigns.template_i
|
|||
WHERE campaigns.id = $1;
|
||||
|
||||
-- name: get-campaign-status
|
||||
SELECT id, status, to_send, sent, started_at, updated_at
|
||||
FROM campaigns
|
||||
WHERE status=$1;
|
||||
SELECT id, status, to_send, sent, started_at, updated_at FROM campaigns WHERE status=$1;
|
||||
|
||||
-- name: campaign-has-lists
|
||||
-- Returns TRUE if the campaign $1 has any of the lists given in $2.
|
||||
SELECT EXISTS (
|
||||
SELECT TRUE FROM campaign_lists WHERE campaign_id = $1 AND list_id = ANY($2::INT[])
|
||||
);
|
||||
|
||||
-- name: next-campaigns
|
||||
-- Retreives campaigns that are running (or scheduled and the time's up) and need
|
||||
|
|
Loading…
Add table
Reference in a new issue