@@ -0,0 +1,112 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var bounceQuerySortFields = []string{"email", "campaign_name", "source", "created_at", "type"}
|
||||
|
||||
// QueryBounces retrieves paginated bounce entries based on the given params.
|
||||
// It also returns the total number of bounce records in the DB.
|
||||
func (c *Core) QueryBounces(campID, subID int, source, orderBy, order string, offset, limit int) ([]models.Bounce, int, error) {
|
||||
if !strSliceContains(orderBy, bounceQuerySortFields) {
|
||||
orderBy = "created_at"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
out := []models.Bounce{}
|
||||
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", orderBy+" "+order)
|
||||
if err := c.db.Select(&out, stmt, 0, campID, subID, source, offset, limit); err != nil {
|
||||
c.log.Printf("error fetching bounces: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetBounce retrieves bounce entries based on the given params.
|
||||
func (c *Core) GetBounce(id int) (models.Bounce, error) {
|
||||
var out []models.Bounce
|
||||
stmt := strings.ReplaceAll(c.q.QueryBounces, "%order%", "id "+SortAsc)
|
||||
if err := c.db.Select(&out, stmt, id, 0, 0, "", 0, 1); err != nil {
|
||||
c.log.Printf("error fetching bounces: %v", err)
|
||||
return models.Bounce{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.bounce}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Bounce{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.bounce}"))
|
||||
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// RecordBounce records a new bounce.
|
||||
func (c *Core) RecordBounce(b models.Bounce) error {
|
||||
action, ok := c.consts.BounceActions[b.Type]
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("globals.messages.invalidData")+": "+b.Type)
|
||||
}
|
||||
|
||||
_, err := c.q.RecordBounce.Exec(b.SubscriberUUID,
|
||||
b.Email,
|
||||
b.CampaignUUID,
|
||||
b.Type,
|
||||
b.Source,
|
||||
b.Meta,
|
||||
b.CreatedAt,
|
||||
action.Count,
|
||||
action.Action)
|
||||
|
||||
if err != nil {
|
||||
// Ignore the error if it complained of no subscriber.
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "subscriber_id" {
|
||||
c.log.Printf("bounced subscriber (%s / %s) not found", b.SubscriberUUID, b.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
c.log.Printf("error recording bounce: %v", err)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// BlocklistBouncedSubscribers blocklists all bounced subscribers.
|
||||
func (c *Core) BlocklistBouncedSubscribers() error {
|
||||
if _, err := c.q.BlocklistBouncedSubscribers.Exec(); err != nil {
|
||||
c.log.Printf("error blocklisting bounced subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBounce deletes a list.
|
||||
func (c *Core) DeleteBounce(id int) error {
|
||||
return c.DeleteBounces([]int{id}, false)
|
||||
}
|
||||
|
||||
// DeleteBounces deletes multiple lists.
|
||||
func (c *Core) DeleteBounces(ids []int, all bool) error {
|
||||
if _, err := c.q.DeleteBounces.Exec(pq.Array(ids), all); err != nil {
|
||||
c.log.Printf("error deleting lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
CampaignAnalyticsViews = "views"
|
||||
CampaignAnalyticsClicks = "clicks"
|
||||
CampaignAnalyticsBounces = "bounces"
|
||||
|
||||
campaignTplDefault = "default"
|
||||
campaignTplArchive = "archive"
|
||||
)
|
||||
|
||||
// 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, getAll bool, permittedLists []int, offset, limit int) (models.Campaigns, int, error) {
|
||||
queryStr, stmt := makeSearchQuery(searchStr, orderBy, order, c.q.QueryCampaigns, campQuerySortFields)
|
||||
|
||||
if statuses == nil {
|
||||
statuses = []string{}
|
||||
}
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
// 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, 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)))
|
||||
}
|
||||
|
||||
for i := range out {
|
||||
// Replace null tags.
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load stats.
|
||||
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetCampaign retrieves a campaign.
|
||||
func (c *Core) GetCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
||||
return c.getCampaign(id, uuid, archiveSlug, campaignTplDefault)
|
||||
}
|
||||
|
||||
// GetArchivedCampaign retrieves a campaign with the archive template body.
|
||||
func (c *Core) GetArchivedCampaign(id int, uuid, archiveSlug string) (models.Campaign, error) {
|
||||
out, err := c.getCampaign(id, uuid, archiveSlug, campaignTplArchive)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
if !out.Archive {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getCampaign retrieves a campaign. If typlType=default, then the campaign's
|
||||
// template body is returned as "template_body". If tplType="archive",
|
||||
// the archive template is returned.
|
||||
func (c *Core) getCampaign(id int, uuid, archiveSlug string, tplType string) (models.Campaign, error) {
|
||||
// Unsafe to ignore scanning fields not present in models.Campaigns.
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.Campaigns
|
||||
if err := c.q.GetCampaign.Select(&out, id, uu, archiveSlug, tplType); err != nil {
|
||||
// if err := c.db.Select(&out, stmt, 0, pq.Array([]string{}), queryStr, 0, 1); err != nil {
|
||||
c.log.Printf("error fetching campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
for i := 0; i < len(out); i++ {
|
||||
// Replace null tags.
|
||||
if out[i].Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load stats.
|
||||
if err := out.LoadStats(c.q.GetCampaignStats); err != nil {
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// GetCampaignForPreview retrieves a campaign with a template body. If the optional tplID is > 0
|
||||
// that particular template is used, otherwise, the template saved on the campaign is.
|
||||
func (c *Core) GetCampaignForPreview(id, tplID int) (models.Campaign, error) {
|
||||
var out models.Campaign
|
||||
if err := c.q.GetCampaignForPreview.Get(&out, id, tplID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
c.log.Printf("error fetching campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetArchivedCampaigns retrieves campaigns with a template body.
|
||||
func (c *Core) GetArchivedCampaigns(offset, limit int) (models.Campaigns, int, error) {
|
||||
var out models.Campaigns
|
||||
if err := c.q.GetArchivedCampaigns.Select(&out, offset, limit, campaignTplArchive); err != nil {
|
||||
c.log.Printf("error fetching public campaigns: %v", err)
|
||||
return models.Campaigns{}, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// CreateCampaign creates a new campaign.
|
||||
func (c *Core) CreateCampaign(o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Insert and read ID.
|
||||
var newID int
|
||||
if err := c.q.CreateCampaign.Get(&newID,
|
||||
uu,
|
||||
o.Type,
|
||||
o.Name,
|
||||
o.Subject,
|
||||
o.FromEmail,
|
||||
o.Body,
|
||||
o.AltBody,
|
||||
o.ContentType,
|
||||
o.SendAt,
|
||||
o.Headers,
|
||||
o.Attribs,
|
||||
pq.StringArray(normalizeTags(o.Tags)),
|
||||
o.Messenger,
|
||||
o.TemplateID,
|
||||
pq.Array(listIDs),
|
||||
o.Archive,
|
||||
o.ArchiveSlug,
|
||||
o.ArchiveTemplateID,
|
||||
o.ArchiveMeta,
|
||||
pq.Array(mediaIDs),
|
||||
o.BodySource,
|
||||
); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noSubs"))
|
||||
}
|
||||
|
||||
c.log.Printf("error creating campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetCampaign(newID, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateCampaign updates a campaign.
|
||||
func (c *Core) UpdateCampaign(id int, o models.Campaign, listIDs []int, mediaIDs []int) (models.Campaign, error) {
|
||||
_, err := c.q.UpdateCampaign.Exec(id,
|
||||
o.Name,
|
||||
o.Subject,
|
||||
o.FromEmail,
|
||||
o.Body,
|
||||
o.AltBody,
|
||||
o.ContentType,
|
||||
o.SendAt,
|
||||
o.Headers,
|
||||
o.Attribs,
|
||||
pq.StringArray(normalizeTags(o.Tags)),
|
||||
o.Messenger,
|
||||
o.TemplateID,
|
||||
pq.Array(listIDs),
|
||||
o.Archive,
|
||||
o.ArchiveSlug,
|
||||
o.ArchiveTemplateID,
|
||||
o.ArchiveMeta,
|
||||
pq.Array(mediaIDs),
|
||||
o.BodySource)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating campaign: %v", err)
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateCampaignStatus updates a campaign's status, eg: draft to running.
|
||||
func (c *Core) UpdateCampaignStatus(id int, status string) (models.Campaign, error) {
|
||||
cm, err := c.GetCampaign(id, "", "")
|
||||
if err != nil {
|
||||
return models.Campaign{}, err
|
||||
}
|
||||
|
||||
errMsg := ""
|
||||
switch status {
|
||||
case models.CampaignStatusDraft:
|
||||
if cm.Status != models.CampaignStatusScheduled {
|
||||
errMsg = c.i18n.T("campaigns.onlyScheduledAsDraft")
|
||||
}
|
||||
case models.CampaignStatusScheduled:
|
||||
if cm.Status != models.CampaignStatusDraft && cm.Status != models.CampaignStatusPaused {
|
||||
errMsg = c.i18n.T("campaigns.onlyDraftAsScheduled")
|
||||
}
|
||||
if !cm.SendAt.Valid {
|
||||
errMsg = c.i18n.T("campaigns.needsSendAt")
|
||||
}
|
||||
|
||||
case models.CampaignStatusRunning:
|
||||
if cm.Status != models.CampaignStatusPaused && cm.Status != models.CampaignStatusDraft {
|
||||
errMsg = c.i18n.T("campaigns.onlyPausedDraft")
|
||||
}
|
||||
case models.CampaignStatusPaused:
|
||||
if cm.Status != models.CampaignStatusRunning {
|
||||
errMsg = c.i18n.T("campaigns.onlyActivePause")
|
||||
}
|
||||
case models.CampaignStatusCancelled:
|
||||
if cm.Status != models.CampaignStatusRunning && cm.Status != models.CampaignStatusPaused {
|
||||
errMsg = c.i18n.T("campaigns.onlyActiveCancel")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errMsg) > 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest, errMsg)
|
||||
}
|
||||
|
||||
res, err := c.q.UpdateCampaignStatus.Exec(cm.ID, status)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating campaign status: %v", err)
|
||||
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.Campaign{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
cm.Status = status
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
// UpdateCampaignArchive updates a campaign's archive properties.
|
||||
func (c *Core) UpdateCampaignArchive(id int, enabled bool, tplID int, meta models.JSON, archiveSlug string) error {
|
||||
if _, err := c.q.UpdateCampaignArchive.Exec(id, enabled, archiveSlug, tplID, meta); err != nil {
|
||||
c.log.Printf("error updating campaign: %v", err)
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaign deletes a campaign.
|
||||
func (c *Core) DeleteCampaign(id int) error {
|
||||
res, err := c.q.DeleteCampaign.Exec(id)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting campaign: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.campaign}"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaigns deletes multiple campaigns by IDs or by query.
|
||||
func (c *Core) DeleteCampaigns(ids []int, query string, hasAllPerm bool, permittedLists []int) error {
|
||||
var queryStr string
|
||||
|
||||
if len(ids) > 0 {
|
||||
queryStr = ""
|
||||
} else {
|
||||
queryStr = makeSearchString(query)
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteCampaigns.Exec(pq.Array(ids), queryStr, hasAllPerm, pq.Array(permittedLists)); err != nil {
|
||||
c.log.Printf("error deleting campaigns: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.campaigns}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
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{}
|
||||
if err := c.q.GetCampaignStatus.Select(&out, models.CampaignStatusRunning); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
c.log.Printf("error fetching campaign stats: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
} else if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Core) GetCampaignAnalyticsCounts(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsCount, error) {
|
||||
// Pick campaign view counts or click counts.
|
||||
var stmt *sqlx.Stmt
|
||||
switch typ {
|
||||
case "views":
|
||||
stmt = c.q.GetCampaignViewCounts
|
||||
case "clicks":
|
||||
stmt = c.q.GetCampaignClickCounts
|
||||
case "bounces":
|
||||
stmt = c.q.GetCampaignBounceCounts
|
||||
default:
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("globals.messages.invalidData"))
|
||||
}
|
||||
|
||||
if !strHasLen(fromDate, 10, 30) || !strHasLen(toDate, 10, 30) {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("analytics.invalidDates"))
|
||||
}
|
||||
|
||||
out := []models.CampaignAnalyticsCount{}
|
||||
if err := stmt.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
||||
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetCampaignAnalyticsLinks returns link click analytics for the given campaign IDs.
|
||||
func (c *Core) GetCampaignAnalyticsLinks(campIDs []int, typ, fromDate, toDate string) ([]models.CampaignAnalyticsLink, error) {
|
||||
out := []models.CampaignAnalyticsLink{}
|
||||
if err := c.q.GetCampaignLinkCounts.Select(&out, pq.Array(campIDs), fromDate, toDate); err != nil {
|
||||
c.log.Printf("error fetching campaign %s: %v", typ, err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RegisterCampaignView registers a subscriber's view on a campaign.
|
||||
func (c *Core) RegisterCampaignView(campUUID, subUUID string) error {
|
||||
if _, err := c.q.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "campaign_id" {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.log.Printf("error registering campaign view: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.campaign}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLinkURL returns the original URL for a link UUID without recording a click.
|
||||
func (c *Core) GetLinkURL(linkUUID string) (string, error) {
|
||||
var url string
|
||||
if err := c.q.GetLinkURL.Get(&url, linkUUID); err != nil {
|
||||
c.log.Printf("error getting link URL: %s", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// RegisterCampaignLinkClick registers a subscriber's link click on a campaign.
|
||||
func (c *Core) RegisterCampaignLinkClick(linkUUID, campUUID, subUUID string) (string, error) {
|
||||
var url string
|
||||
if err := c.q.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
|
||||
return "", echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("public.invalidLink"))
|
||||
}
|
||||
|
||||
c.log.Printf("error registering link click: %s", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// ExportCampaignViews returns an iterator with campaign views for streaming/exporting.
|
||||
func (c *Core) ExportCampaignViews(since time.Time, batchSize int) func() ([]models.CampaignViewExport, error) {
|
||||
offset := 0
|
||||
return func() ([]models.CampaignViewExport, error) {
|
||||
var out []models.CampaignViewExport
|
||||
if err := c.q.ExportCampaignViews.Select(&out, since, batchSize, offset); err != nil {
|
||||
c.log.Printf("error exporting campaign views: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
offset += len(out)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ExportCampaignLinkClicks returns an iterator with campaign link click for streaming/exporting.
|
||||
func (c *Core) ExportCampaignLinkClicks(since time.Time, batchSize int) func() ([]models.CampaignClickExport, error) {
|
||||
offset := 0
|
||||
return func() ([]models.CampaignClickExport, error) {
|
||||
var out []models.CampaignClickExport
|
||||
if err := c.q.ExportCampaignLinkClicks.Select(&out, since, batchSize, offset); err != nil {
|
||||
c.log.Printf("error exporting campaign link clicks: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.analytics}", "error", pqErrMsg(err)))
|
||||
}
|
||||
offset += len(out)
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteCampaignViews deletes campaign views older than a given date.
|
||||
func (c *Core) DeleteCampaignViews(before time.Time) error {
|
||||
if _, err := c.q.DeleteCampaignViews.Exec(before); err != nil {
|
||||
c.log.Printf("error deleting campaign views: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCampaignLinkClicks deletes campaign views older than a given date.
|
||||
func (c *Core) DeleteCampaignLinkClicks(before time.Time) error {
|
||||
if _, err := c.q.DeleteCampaignLinkClicks.Exec(before); err != nil {
|
||||
c.log.Printf("error deleting campaign link clicks: %s", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("public.errorProcessingRequest"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// package core is the collection of re-usable functions that primarily provides data (DB / CRUD) operations
|
||||
// to the app. For instance, creating and mutating objects like lists, subscribers etc.
|
||||
// All such methods return an echo.HTTPError{} (which implements error.error) that can be directly returned
|
||||
// as a response to HTTP handlers without further processing.
|
||||
package core
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/i18n"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
SortAsc = "asc"
|
||||
SortDesc = "desc"
|
||||
|
||||
matDashboardCharts = "mat_dashboard_charts"
|
||||
matDashboardCounts = "mat_dashboard_counts"
|
||||
matListSubStats = "mat_list_subscriber_stats"
|
||||
)
|
||||
|
||||
// Core represents the eaglecast core with all shared, global functions.
|
||||
type Core struct {
|
||||
h *Hooks
|
||||
|
||||
consts Constants
|
||||
i18n *i18n.I18n
|
||||
db *sqlx.DB
|
||||
q *models.Queries
|
||||
log *log.Logger
|
||||
}
|
||||
|
||||
// Constants represents constant config.
|
||||
type Constants struct {
|
||||
SendOptinConfirmation bool
|
||||
BounceActions map[string]struct {
|
||||
Count int
|
||||
Action string
|
||||
}
|
||||
CacheSlowQueries bool
|
||||
}
|
||||
|
||||
// Hooks contains external function hooks that are required by the core package.
|
||||
type Hooks struct {
|
||||
SendOptinConfirmation func(models.Subscriber, []int) (int, error)
|
||||
}
|
||||
|
||||
// Opt contains the controllers required to start the core.
|
||||
type Opt struct {
|
||||
Constants Constants
|
||||
I18n *i18n.I18n
|
||||
DB *sqlx.DB
|
||||
Queries *models.Queries
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNotFound = echo.NewHTTPError(http.StatusNotFound, "not found")
|
||||
)
|
||||
|
||||
var (
|
||||
regexFullTextQuery = regexp.MustCompile(`\s+`)
|
||||
regexpSpaces = regexp.MustCompile(`[\s]+`)
|
||||
campQuerySortFields = []string{"name", "status", "created_at", "updated_at"}
|
||||
subQuerySortFields = []string{"email", "status", "name", "created_at", "updated_at"}
|
||||
listQuerySortFields = []string{"name", "status", "created_at", "updated_at", "subscriber_count"}
|
||||
)
|
||||
|
||||
// New returns a new instance of the core.
|
||||
func New(o *Opt, h *Hooks) *Core {
|
||||
return &Core{
|
||||
h: h,
|
||||
consts: o.Constants,
|
||||
i18n: o.I18n,
|
||||
db: o.DB,
|
||||
q: o.Queries,
|
||||
log: o.Log,
|
||||
}
|
||||
}
|
||||
|
||||
// RefreshMatViews refreshes all materialized views.
|
||||
func (c *Core) RefreshMatViews(concurrent bool) error {
|
||||
for _, v := range []string{matDashboardCharts, matDashboardCounts, matListSubStats} {
|
||||
_ = c.RefreshMatView(v, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshMatView refreshes a Postgres materialized view.
|
||||
func (c *Core) RefreshMatView(name string, concurrent bool) error {
|
||||
q := "REFRESH MATERIALIZED VIEW %s %s"
|
||||
if concurrent {
|
||||
q = fmt.Sprintf(q, "CONCURRENTLY", name)
|
||||
} else {
|
||||
q = fmt.Sprintf(q, "", name)
|
||||
}
|
||||
|
||||
if _, err := c.db.Exec(q); err != nil {
|
||||
c.log.Printf("error refreshing materialized view: %s: %v", name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshCache refreshes a Postgres materialized view if caching is disabled.
|
||||
func (c *Core) refreshCache(name string, concurrent bool) error {
|
||||
if c.consts.CacheSlowQueries {
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.RefreshMatView(name, concurrent)
|
||||
}
|
||||
|
||||
// Given an error, pqErrMsg will try to return pq error details
|
||||
// if it's a pq error.
|
||||
func pqErrMsg(err error) string {
|
||||
if err, ok := err.(*pq.Error); ok {
|
||||
if err.Detail != "" {
|
||||
return fmt.Sprintf("%s. %s", err, err.Detail)
|
||||
}
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// makeSearchQuery cleans an optional search string and prepares the
|
||||
// query SQL statement (string interpolated) and returns the
|
||||
// search query string along with the SQL expression.
|
||||
func makeSearchQuery(searchStr, orderBy, order, query string, querySortFields []string) (string, string) {
|
||||
searchStr = makeSearchString(searchStr)
|
||||
|
||||
// Sort params.
|
||||
if !strSliceContains(orderBy, querySortFields) {
|
||||
orderBy = "created_at"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
query = strings.ReplaceAll(query, "%order%", orderBy+" "+order)
|
||||
|
||||
return searchStr, query
|
||||
}
|
||||
|
||||
// makeSearchString prepares a search string for use in both tsquery and ILIKE queries.
|
||||
func makeSearchString(searchStr string) string {
|
||||
if searchStr == "" {
|
||||
return ""
|
||||
}
|
||||
return `%` + string(regexFullTextQuery.ReplaceAll([]byte(searchStr), []byte("&"))) + `%`
|
||||
}
|
||||
|
||||
// strSliceContains checks if a string is present in the string slice.
|
||||
func strSliceContains(str string, sl []string) bool {
|
||||
for _, s := range sl {
|
||||
if s == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeTags takes a list of string tags and normalizes them by
|
||||
// lower casing and removing all special characters except for dashes.
|
||||
func normalizeTags(tags []string) []string {
|
||||
var (
|
||||
out []string
|
||||
dash = []byte("-")
|
||||
)
|
||||
|
||||
for _, t := range tags {
|
||||
rep := regexpSpaces.ReplaceAll(bytes.TrimSpace([]byte(t)), dash)
|
||||
|
||||
if len(rep) > 0 {
|
||||
out = append(out, string(rep))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sanitizeSQLExp does basic sanitisation on arbitrary
|
||||
// SQL query expressions coming from the frontend.
|
||||
func sanitizeSQLExp(q string) string {
|
||||
if len(q) == 0 {
|
||||
return ""
|
||||
}
|
||||
q = strings.TrimSpace(q)
|
||||
|
||||
// Remove semicolon suffix.
|
||||
if q[len(q)-1] == ';' {
|
||||
q = q[:len(q)-1]
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// strHasLen checks if the given string has a length within min-max.
|
||||
func strHasLen(str string, min, max int) bool {
|
||||
return len(str) >= min && len(str) <= max
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetDashboardCharts returns chart data points to render on the dashboard.
|
||||
func (c *Core) GetDashboardCharts() (types.JSONText, error) {
|
||||
_ = c.refreshCache(matDashboardCharts, false)
|
||||
|
||||
var out types.JSONText
|
||||
if err := c.q.GetDashboardCharts.Get(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard charts", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetDashboardCounts returns stats counts to show on the dashboard.
|
||||
func (c *Core) GetDashboardCounts() (types.JSONText, error) {
|
||||
_ = c.refreshCache(matDashboardCounts, false)
|
||||
|
||||
var out types.JSONText
|
||||
if err := c.q.GetDashboardCounts.Get(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "dashboard stats", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type listType struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// GetLists gets all lists optionally filtered by type and status.
|
||||
func (c *Core) GetLists(typ, status string, getAll bool, permittedIDs []int) ([]models.List, error) {
|
||||
out := []models.List{}
|
||||
|
||||
if err := c.q.GetLists.Select(&out, typ, status, "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)))
|
||||
}
|
||||
|
||||
// Replace null tags.
|
||||
for i, l := range out {
|
||||
if l.Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
|
||||
// Total counts.
|
||||
for _, c := range l.SubscriberCounts {
|
||||
out[i].SubscriberCount += c
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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, status string, tags []string, orderBy, order string, getAll bool, permittedIDs []int, offset, limit int) ([]models.List, int, error) {
|
||||
_ = c.refreshCache(matListSubStats, false)
|
||||
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
var (
|
||||
out = []models.List{}
|
||||
queryStr, stmt = makeSearchQuery(searchStr, orderBy, order, c.q.QueryLists, listQuerySortFields)
|
||||
)
|
||||
if err := c.db.Select(&out, stmt, 0, "", queryStr, typ, optin, status, 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)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
|
||||
// Replace null tags.
|
||||
for i, l := range out {
|
||||
if l.Tags == nil {
|
||||
out[i].Tags = []string{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetList gets a list by its ID or UUID.
|
||||
func (c *Core) GetList(id int, uuid string) (models.List, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var res []models.List
|
||||
queryStr, stmt := makeSearchQuery("", "", "", c.q.QueryLists, 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)))
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
|
||||
}
|
||||
|
||||
out := res[0]
|
||||
if out.Tags == nil {
|
||||
out.Tags = []string{}
|
||||
}
|
||||
// Total counts.
|
||||
for _, c := range out.SubscriberCounts {
|
||||
out.SubscriberCount += c
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetListsByOptin returns lists by optin type.
|
||||
func (c *Core) GetListsByOptin(ids []int, optinType string) ([]models.List, error) {
|
||||
out := []models.List{}
|
||||
if err := c.q.GetListsByOptin.Select(&out, optinType, pq.Array(ids), nil); err != nil {
|
||||
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetListTypes returns lists by their IDs or UUIDs.
|
||||
// If ids is given, then the map returned has the list IDs as keys,
|
||||
// otherwise, they have UUIDs as the keys.
|
||||
// Note: This is a really weird and awkward API. Ideally, Go Generics
|
||||
// should've somehow supported generic struct methods.
|
||||
func (c *Core) GetListTypes(ids []int, uuids []string) (map[any]string, error) {
|
||||
res := []listType{}
|
||||
|
||||
out := map[any]string{}
|
||||
if err := c.q.GetListTypes.Select(&res, pq.Array(ids), pq.StringArray(uuids)); err != nil {
|
||||
c.log.Printf("error fetching list types: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
isIDs := ids != nil
|
||||
for _, r := range res {
|
||||
if isIDs {
|
||||
out[r.ID] = r.Type
|
||||
} else {
|
||||
out[r.UUID] = r.Type
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateList creates a new list.
|
||||
func (c *Core) CreateList(l models.List) (models.List, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
if l.Type == "" {
|
||||
l.Type = models.ListTypePrivate
|
||||
}
|
||||
if l.Optin == "" {
|
||||
l.Optin = models.ListOptinSingle
|
||||
}
|
||||
if l.Status == "" {
|
||||
l.Status = models.ListStatusActive
|
||||
}
|
||||
|
||||
// Insert and read ID.
|
||||
var newID int
|
||||
l.UUID = uu.String()
|
||||
if err := c.q.CreateList.Get(&newID, l.UUID, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description); err != nil {
|
||||
c.log.Printf("error creating list: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetList(newID, "")
|
||||
}
|
||||
|
||||
// UpdateList updates a given list.
|
||||
func (c *Core) UpdateList(id int, l models.List) (models.List, error) {
|
||||
res, err := c.q.UpdateList.Exec(id, l.Name, l.Type, l.Optin, l.Status, pq.StringArray(normalizeTags(l.Tags)), l.Description)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating list: %v", err)
|
||||
return models.List{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.list}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.List{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.list}"))
|
||||
}
|
||||
|
||||
return c.GetList(id, "")
|
||||
}
|
||||
|
||||
// DeleteList deletes a list.
|
||||
func (c *Core) DeleteList(id int) error {
|
||||
return c.DeleteLists([]int{id}, "", true, nil)
|
||||
}
|
||||
|
||||
// DeleteLists deletes multiple lists.
|
||||
func (c *Core) DeleteLists(ids []int, query string, getAll bool, permittedIDs []int) error {
|
||||
var queryStr string
|
||||
|
||||
if len(ids) > 0 {
|
||||
queryStr = ""
|
||||
} else {
|
||||
queryStr = makeSearchString(query)
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteLists.Exec(pq.Array(ids), queryStr, getAll, pq.Array(permittedIDs)); err != nil {
|
||||
c.log.Printf("error deleting lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/media"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// QueryMedia returns media entries optionally filtered by a query string.
|
||||
func (c *Core) QueryMedia(provider string, s media.Store, query string, offset, limit int) ([]media.Media, int, error) {
|
||||
out := []media.Media{}
|
||||
|
||||
if query != "" {
|
||||
query = strings.ToLower(query)
|
||||
}
|
||||
|
||||
if err := c.q.QueryMedia.Select(&out, fmt.Sprintf("%%%s%%", query), provider, offset, limit); err != nil {
|
||||
return out, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
total := 0
|
||||
if len(out) > 0 {
|
||||
total = out[0].Total
|
||||
|
||||
for i := 0; i < len(out); i++ {
|
||||
out[i].URL = s.GetURL(out[i].Filename)
|
||||
|
||||
if out[i].Thumb != "" {
|
||||
out[i].ThumbURL = null.String{Valid: true, String: s.GetURL(out[i].Thumb)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetMedia returns a media item.
|
||||
func (c *Core) GetMedia(id int, uuid, fileName string, s media.Store) (media.Media, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out media.Media
|
||||
if err := c.q.GetMedia.Get(&out, id, uu, fileName); err != nil {
|
||||
// If it's ` sql: no rows in result set`, return a 404.
|
||||
if err == sql.ErrNoRows {
|
||||
return out, ErrNotFound
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out.URL = s.GetURL(out.Filename)
|
||||
if out.Thumb != "" {
|
||||
out.ThumbURL = null.String{Valid: true, String: s.GetURL(out.Thumb)}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InsertMedia inserts a new media file into the DB.
|
||||
func (c *Core) InsertMedia(fileName, thumbName, contentType string, meta models.JSON, provider string, s media.Store) (media.Media, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Write to the DB.
|
||||
var newID int
|
||||
if err := c.q.InsertMedia.Get(&newID, uu, fileName, thumbName, contentType, provider, meta); err != nil {
|
||||
c.log.Printf("error inserting uploaded file to db: %v", err)
|
||||
return media.Media{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetMedia(newID, "", "", s)
|
||||
}
|
||||
|
||||
// DeleteMedia deletes a given media item and returns the filename of the deleted item.
|
||||
func (c *Core) DeleteMedia(id int) (string, error) {
|
||||
var fname string
|
||||
if err := c.q.DeleteMedia.Get(&fname, id); err != nil {
|
||||
c.log.Printf("error inserting uploaded file to db: %v", err)
|
||||
return "", echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.media}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return fname, nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetRoles retrieves all roles.
|
||||
func (c *Core) GetRoles() ([]auth.Role, error) {
|
||||
out := []auth.Role{}
|
||||
if err := c.q.GetUserRoles.Select(&out, nil); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetRole retrieves a role.
|
||||
func (c *Core) GetRole(id int) (auth.Role, error) {
|
||||
out := []auth.Role{}
|
||||
if err := c.q.GetUserRoles.Select(&out, id); err != nil {
|
||||
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Role does not exist.
|
||||
if len(out) == 0 {
|
||||
return auth.Role{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "role", "error", "role not found"))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// GetListRoles retrieves all list roles.
|
||||
func (c *Core) GetListRoles() ([]auth.ListRole, error) {
|
||||
out := []auth.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 auth.Role) (auth.Role, error) {
|
||||
var out auth.Role
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, auth.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 auth.ListRole) (auth.ListRole, error) {
|
||||
var out auth.ListRole
|
||||
|
||||
if err := c.q.CreateRole.Get(&out, r.Name, auth.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 []auth.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 auth.Role) (auth.Role, error) {
|
||||
var out auth.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 auth.ListRole) (auth.ListRole, error) {
|
||||
var out auth.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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/jmoiron/sqlx/types"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
// GetSettings returns settings from the DB.
|
||||
func (c *Core) GetSettings() (models.Settings, error) {
|
||||
var (
|
||||
b types.JSONText
|
||||
out models.Settings
|
||||
)
|
||||
|
||||
if err := c.q.GetSettings.Get(&b); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Unmarshal the settings and filter out sensitive fields.
|
||||
if err := json.Unmarshal([]byte(b), &out); err != nil {
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSettings updates settings.
|
||||
func (c *Core) UpdateSettings(s models.Settings) error {
|
||||
// Marshal settings.
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("settings.errorEncoding", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Update the settings in the DB.
|
||||
if _, err := c.q.UpdateSettings.Exec(b); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSettingsByKey updates a single setting by key.
|
||||
func (c *Core) UpdateSettingsByKey(key string, value json.RawMessage) error {
|
||||
if _, err := c.q.UpdateSettingsByKey.Exec(key, value); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.settings}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
var (
|
||||
allowedSubQueryTables = map[string]struct{}{
|
||||
"subscribers": {},
|
||||
"lists": {},
|
||||
"subscriber_lists": {},
|
||||
"campaigns": {},
|
||||
"campaign_lists": {},
|
||||
"campaign_views": {},
|
||||
"links": {},
|
||||
"link_clicks": {},
|
||||
"bounces": {},
|
||||
}
|
||||
)
|
||||
|
||||
// GetSubscriber fetches a subscriber by one of the given params.
|
||||
func (c *Core) GetSubscriber(id int, uuid, email string) (models.Subscriber, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.Subscribers
|
||||
if err := c.q.GetSubscriber.Select(&out, id, uu, email); err != nil {
|
||||
c.log.Printf("error fetching subscriber: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name",
|
||||
fmt.Sprintf("{globals.terms.subscriber} (%d: %s%s)", id, uuid, email)))
|
||||
}
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error loading subscriber lists: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching",
|
||||
"name", "{globals.terms.lists}", "error", pqErrMsg(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
|
||||
|
||||
if err := c.q.GetSubscribersByEmails.Select(&out, pq.Array(emails)); 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)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("campaigns.noKnownSubsToTest"))
|
||||
}
|
||||
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error loading subscriber lists: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// QuerySubscribers queries and returns paginated subscrribers based on the given params including the total count.
|
||||
func (c *Core) QuerySubscribers(searchStr, queryExp string, listIDs []int, subStatus string, order, orderBy string, offset, limit int) (models.Subscribers, int, error) {
|
||||
// Sort params.
|
||||
if !strSliceContains(orderBy, subQuerySortFields) {
|
||||
orderBy = "subscribers.id"
|
||||
}
|
||||
if order != SortAsc && order != SortDesc {
|
||||
order = SortDesc
|
||||
}
|
||||
|
||||
// Required for pq.Array()
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
|
||||
// There's an arbitrary query condition.
|
||||
cond := "TRUE"
|
||||
if queryExp != "" {
|
||||
cond = queryExp
|
||||
}
|
||||
|
||||
// stmt is the raw SQL query.
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribers, "%query%", cond)
|
||||
stmt = strings.ReplaceAll(stmt, "%order%", orderBy+" "+order)
|
||||
|
||||
// Validate the tables used in the query.
|
||||
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
|
||||
c.log.Printf("error validating query tables: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
total, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs)
|
||||
if err != nil {
|
||||
c.log.Printf("error getting subscriber count: %v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// No results.
|
||||
if total == 0 {
|
||||
return models.Subscribers{}, 0, nil
|
||||
}
|
||||
|
||||
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var out models.Subscribers
|
||||
if err := tx.Select(&out, stmt, pq.Array(listIDs), subStatus, searchStr, offset, limit); err != nil {
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Lazy load lists for each subscriber.
|
||||
if err := out.LoadLists(c.q.GetSubscriberListsLazy); err != nil {
|
||||
c.log.Printf("error fetching subscriber lists: %v", err)
|
||||
return nil, 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// GetSubscriberLists returns a subscriber's lists based on the given conditions.
|
||||
func (c *Core) GetSubscriberLists(subID int, uuid string, listIDs []int, listUUIDs []string, subStatus string, listType string) ([]models.List, error) {
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
if listUUIDs == nil {
|
||||
listUUIDs = []string{}
|
||||
}
|
||||
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
// Fetch double opt-in lists from the given list IDs.
|
||||
// Get the list of subscription lists where the subscriber hasn't confirmed.
|
||||
out := []models.List{}
|
||||
if err := c.q.GetSubscriberLists.Select(&out, subID, uu, pq.Array(listIDs), pq.Array(listUUIDs), subStatus, listType); err != nil {
|
||||
c.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscriberProfileForExport returns the subscriber's profile data as a JSON exportable.
|
||||
// Get the subscriber's data. A single query that gets the profile, list subscriptions, campaign views,
|
||||
// and link clicks. Names of private lists are replaced with "Private list".
|
||||
func (c *Core) GetSubscriberProfileForExport(id int, uuid string) (models.SubscriberExportProfile, error) {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
var out models.SubscriberExportProfile
|
||||
if err := c.q.ExportSubscriberData.Get(&out, id, uu); err != nil {
|
||||
c.log.Printf("error fetching subscriber export data: %v", err)
|
||||
|
||||
return models.SubscriberExportProfile{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetSubscriberActivity returns the subscriber's campaign views and link clicks for the Activity tab.
|
||||
func (c *Core) GetSubscriberActivity(id int) (models.SubscriberActivity, error) {
|
||||
var out models.SubscriberActivity
|
||||
if err := c.q.GetSubscriberActivity.Get(&out, id); err != nil {
|
||||
c.log.Printf("error fetching subscriber activity: %v", err)
|
||||
|
||||
return models.SubscriberActivity{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "activity", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportSubscribers returns an iterator function that provides lists of subscribers based
|
||||
// on the given criteria in an exportable form. The iterator function returned can be called
|
||||
// repeatedly until there are nil subscribers. It's an iterator because exports can be extremely
|
||||
// large and may have to be fetched in batches from the DB and streamed somewhere.
|
||||
func (c *Core) ExportSubscribers(searchStr, query string, subIDs, listIDs []int, subStatus string, batchSize int) (func() ([]models.SubscriberExport, error), error) {
|
||||
if subIDs == nil {
|
||||
subIDs = []int{}
|
||||
}
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
|
||||
// There's an arbitrary query condition.
|
||||
cond := "TRUE"
|
||||
if query != "" {
|
||||
cond = query
|
||||
}
|
||||
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribersForExport, "%query%", cond)
|
||||
|
||||
// Validate the tables used in the query.
|
||||
if err := validateQueryTables(c.db, stmt, allowedSubQueryTables,
|
||||
pq.Array(listIDs), 0, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
|
||||
c.log.Printf("error validating query tables: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", err.Error()))
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
if _, err := c.getSubscriberCount(searchStr, cond, subStatus, listIDs); err != nil {
|
||||
c.log.Printf("error getting subscriber count: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prepare the actual query statement.
|
||||
tx, err := c.db.Preparex(stmt)
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
id := 0
|
||||
return func() ([]models.SubscriberExport, error) {
|
||||
var out []models.SubscriberExport
|
||||
if err := tx.Select(&out, pq.Array(listIDs), id, pq.Array(subIDs), subStatus, searchStr, batchSize); err != nil {
|
||||
c.log.Printf("error exporting subscribers by query: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
id = out[len(out)-1].ID
|
||||
return out, nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InsertSubscriber inserts a subscriber and returns the ID. The first bool indicates if
|
||||
// it was a new subscriber, and the second bool indicates if the subscriber was sent an optin confirmation.
|
||||
// bool = optinSent?
|
||||
func (c *Core) InsertSubscriber(sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, assertOptin bool) (models.Subscriber, bool, error) {
|
||||
uu, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
c.log.Printf("error generating UUID: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUUID", "error", err.Error()))
|
||||
}
|
||||
sub.UUID = uu.String()
|
||||
|
||||
subStatus := models.SubscriptionStatusUnconfirmed
|
||||
if preconfirm {
|
||||
subStatus = models.SubscriptionStatusConfirmed
|
||||
}
|
||||
if sub.Status == "" {
|
||||
sub.Status = auth.UserStatusEnabled
|
||||
}
|
||||
|
||||
// For pq.Array()
|
||||
if listIDs == nil {
|
||||
listIDs = []int{}
|
||||
}
|
||||
if listUUIDs == nil {
|
||||
listUUIDs = []string{}
|
||||
}
|
||||
|
||||
if err = c.q.InsertSubscriber.Get(&sub.ID,
|
||||
sub.UUID,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
sub.Attribs,
|
||||
pq.Array(listIDs),
|
||||
pq.Array(listUUIDs),
|
||||
subStatus); err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusConflict, c.i18n.T("subscribers.emailExists"))
|
||||
} else {
|
||||
// return sub.Subscriber, errSubscriberExists
|
||||
c.log.Printf("error inserting subscriber: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the subscriber's full data. If the subscriber already existed and wasn't
|
||||
// created, the id will be empty. Fetch the details by e-mail then.
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, false, err
|
||||
}
|
||||
|
||||
hasOptin := false
|
||||
if !preconfirm && c.consts.SendOptinConfirmation {
|
||||
// Send a confirmation e-mail (if there are any double opt-in lists).
|
||||
num, err := c.h.SendOptinConfirmation(out, listIDs)
|
||||
if assertOptin && err != nil {
|
||||
return out, hasOptin, err
|
||||
}
|
||||
|
||||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
return out, hasOptin, nil
|
||||
}
|
||||
|
||||
// UpdateSubscriber updates a subscriber's properties.
|
||||
func (c *Core) UpdateSubscriber(id int, sub models.Subscriber) (models.Subscriber, error) {
|
||||
// Format raw JSON attributes.
|
||||
attribs := []byte("{}")
|
||||
if len(sub.Attribs) > 0 {
|
||||
if b, err := json.Marshal(sub.Attribs); err != nil {
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating",
|
||||
"name", "{globals.terms.subscriber}", "error", err.Error()))
|
||||
} else {
|
||||
attribs = b
|
||||
}
|
||||
}
|
||||
|
||||
_, err := c.q.UpdateSubscriber.Exec(id,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
json.RawMessage(attribs),
|
||||
)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating subscriber: %v", err)
|
||||
return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateSubscriberWithLists updates a subscriber's properties.
|
||||
// If deleteLists is set to true, all existing subscriptions are deleted and only
|
||||
// the ones provided are added or retained.
|
||||
func (c *Core) UpdateSubscriberWithLists(id int, sub models.Subscriber, listIDs []int, listUUIDs []string, preconfirm, deleteLists, assertOptin bool, permittedListIDs []int, allowResubscribe bool) (models.Subscriber, bool, error) {
|
||||
subStatus := models.SubscriptionStatusUnconfirmed
|
||||
if preconfirm {
|
||||
subStatus = models.SubscriptionStatusConfirmed
|
||||
}
|
||||
|
||||
// Format raw JSON attributes.
|
||||
attribs := []byte("{}")
|
||||
if len(sub.Attribs) > 0 {
|
||||
if b, err := json.Marshal(sub.Attribs); err != nil {
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating",
|
||||
"name", "{globals.terms.subscriber}", "error", err.Error()))
|
||||
} else {
|
||||
attribs = b
|
||||
}
|
||||
}
|
||||
|
||||
_, err := c.q.UpdateSubscriberWithLists.Exec(id,
|
||||
sub.Email,
|
||||
strings.TrimSpace(sub.Name),
|
||||
sub.Status,
|
||||
json.RawMessage(attribs),
|
||||
pq.Array(listIDs),
|
||||
pq.Array(listUUIDs),
|
||||
subStatus,
|
||||
deleteLists,
|
||||
pq.Array(permittedListIDs),
|
||||
allowResubscribe)
|
||||
if err != nil {
|
||||
c.log.Printf("error updating subscriber: %v", err)
|
||||
return models.Subscriber{}, false, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
out, err := c.GetSubscriber(sub.ID, "", sub.Email)
|
||||
if err != nil {
|
||||
return models.Subscriber{}, false, err
|
||||
}
|
||||
|
||||
hasOptin := false
|
||||
if !preconfirm && c.consts.SendOptinConfirmation {
|
||||
// Send a confirmation e-mail (if there are any double opt-in lists).
|
||||
num, err := c.h.SendOptinConfirmation(out, listIDs)
|
||||
if assertOptin && err != nil {
|
||||
return out, hasOptin, err
|
||||
}
|
||||
hasOptin = num > 0
|
||||
}
|
||||
|
||||
return out, hasOptin, nil
|
||||
}
|
||||
|
||||
// BlocklistSubscribers blocklists the given list of subscribers.
|
||||
func (c *Core) BlocklistSubscribers(subIDs []int) error {
|
||||
if _, err := c.q.BlocklistSubscribers.Exec(pq.Array(subIDs)); err != nil {
|
||||
c.log.Printf("error blocklisting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlocklistSubscribersByQuery blocklists the given list of subscribers.
|
||||
func (c *Core) BlocklistSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
|
||||
if err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.BlocklistSubscribersByQuery, listIDs, c.db, subStatus); err != nil {
|
||||
c.log.Printf("error blocklisting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscribers deletes the given list of subscribers.
|
||||
func (c *Core) DeleteSubscribers(subIDs []int, subUUIDs []string) error {
|
||||
if subIDs == nil {
|
||||
subIDs = []int{}
|
||||
}
|
||||
if subUUIDs == nil {
|
||||
subUUIDs = []string{}
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteSubscribers.Exec(pq.Array(subIDs), pq.Array(subUUIDs)); err != nil {
|
||||
c.log.Printf("error deleting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscribersByQuery deletes subscribers by a given arbitrary query expression.
|
||||
func (c *Core) DeleteSubscribersByQuery(searchStr, queryExp string, listIDs []int, subStatus string) error {
|
||||
err := c.q.ExecSubQueryTpl(searchStr, sanitizeSQLExp(queryExp), c.q.DeleteSubscribersByQuery, listIDs, c.db, subStatus)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting subscribers: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// UnsubscribeByCampaign unsubscribes a given subscriber from lists in a given campaign.
|
||||
func (c *Core) UnsubscribeByCampaign(subUUID, campUUID string, blocklist bool) error {
|
||||
if _, err := c.q.UnsubscribeByCampaign.Exec(campUUID, subUUID, blocklist); err != nil {
|
||||
c.log.Printf("error unsubscribing: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfirmOptionSubscription confirms a subscriber's optin subscription.
|
||||
func (c *Core) ConfirmOptionSubscription(subUUID string, listUUIDs []string, meta models.JSON) error {
|
||||
if meta == nil {
|
||||
meta = models.JSON{}
|
||||
}
|
||||
|
||||
if _, err := c.q.ConfirmSubscriptionOptin.Exec(subUUID, pq.Array(listUUIDs), meta); err != nil {
|
||||
c.log.Printf("error confirming subscription: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriberBounces deletes the given list of subscribers.
|
||||
func (c *Core) DeleteSubscriberBounces(id int, uuid string) error {
|
||||
var uu any
|
||||
if uuid != "" {
|
||||
uu = uuid
|
||||
}
|
||||
|
||||
if _, err := c.q.DeleteBouncesBySubscriber.Exec(id, uu); err != nil {
|
||||
c.log.Printf("error deleting bounces: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOrphanSubscribers deletes orphan subscriber records (subscribers without lists).
|
||||
func (c *Core) DeleteOrphanSubscribers() (int, error) {
|
||||
res, err := c.q.DeleteOrphanSubscribers.Exec()
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting orphan subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// DeleteBlocklistedSubscribers deletes blocklisted subscribers.
|
||||
func (c *Core) DeleteBlocklistedSubscribers() (int, error) {
|
||||
res, err := c.q.DeleteBlocklistedSubscribers.Exec()
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting blocklisted subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
func (c *Core) getSubscriberCount(searchStr, queryExp, subStatus string, listIDs []int) (int, error) {
|
||||
// If there's no condition, it's a "get all" call which can probably be optionally pulled from cache.
|
||||
if queryExp == "" {
|
||||
_ = c.refreshCache(matListSubStats, false)
|
||||
|
||||
total := 0
|
||||
if err := c.q.QuerySubscribersCountAll.Get(&total, pq.Array(listIDs), subStatus); err != nil {
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// Create a readonly transaction that just does COUNT() to obtain the count of results
|
||||
// and to ensure that the arbitrary query is indeed readonly.
|
||||
stmt := strings.ReplaceAll(c.q.QuerySubscribersCount, "%query%", queryExp)
|
||||
tx, err := c.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
c.log.Printf("error preparing subscriber query: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusBadRequest, c.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Execute the readonly query and get the count of results.
|
||||
total := 0
|
||||
if err := tx.Get(&total, stmt, pq.Array(listIDs), subStatus, searchStr); err != nil {
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// validateQueryTables checks if the query accesses only allowed tables.
|
||||
func validateQueryTables(db *sqlx.DB, query string, allowedTables map[string]struct{}, args ...any) error {
|
||||
// Get the EXPLAIN (FORMAT JSON) output.
|
||||
tx, err := db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var plan string
|
||||
if err = tx.QueryRow("EXPLAIN (FORMAT JSON) "+query, args...).Scan(&plan); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract all relation names from the JSON plan.
|
||||
tables, err := getTablesFromQueryPlan(plan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting tables from query: %v", err)
|
||||
}
|
||||
|
||||
// Validate against allowed tables.
|
||||
for _, table := range tables {
|
||||
if _, ok := allowedTables[table]; !ok {
|
||||
return fmt.Errorf("table '%s' is not allowed", table)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTablesFromQueryPlan parses the EXPLAIN JSON to find all "Relation Name" entries.
|
||||
func getTablesFromQueryPlan(explainJSON string) ([]string, error) {
|
||||
var plans []map[string]any
|
||||
if err := json.Unmarshal([]byte(explainJSON), &plans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect table names in `tables` recursively.
|
||||
tables := make(map[string]struct{})
|
||||
for _, plan := range plans {
|
||||
traverseQueryPlan(plan, tables)
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(tables))
|
||||
for table := range tables {
|
||||
result = append(result, table)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func traverseQueryPlan(node map[string]any, tables map[string]struct{}) {
|
||||
if relName, ok := node["Relation Name"].(string); ok {
|
||||
tables[relName] = struct{}{}
|
||||
}
|
||||
|
||||
// Recursively check nested plans (e.g., subqueries, CTEs).
|
||||
for _, v := range node {
|
||||
switch v := v.(type) {
|
||||
case map[string]any:
|
||||
traverseQueryPlan(v, tables)
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
traverseQueryPlan(m, tables)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// GetSubscriptions retrieves the subscriptions for a subscriber.
|
||||
func (c *Core) GetSubscriptions(subID int, subUUID string, allLists bool) ([]models.Subscription, error) {
|
||||
var out []models.Subscription
|
||||
err := c.q.GetSubscriptions.Select(&out, subID, subUUID, allLists)
|
||||
if err != nil {
|
||||
c.log.Printf("error getting subscriptions: %v", err)
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// AddSubscriptions adds list subscriptions to subscribers.
|
||||
func (c *Core) AddSubscriptions(subIDs, listIDs []int, status string) error {
|
||||
if _, err := c.q.AddSubscribersToLists.Exec(pq.Array(subIDs), pq.Array(listIDs), status); err != nil {
|
||||
c.log.Printf("error adding subscriptions: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddSubscriptionsByQuery adds list subscriptions to subscribers by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) AddSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, status string, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.AddSubscribersToListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs), status)
|
||||
if err != nil {
|
||||
c.log.Printf("error adding subscriptions by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriptions delete list subscriptions from subscribers.
|
||||
func (c *Core) DeleteSubscriptions(subIDs, listIDs []int) error {
|
||||
if _, err := c.q.DeleteSubscriptions.Exec(pq.Array(subIDs), pq.Array(listIDs)); err != nil {
|
||||
c.log.Printf("error deleting subscriptions: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSubscriptionsByQuery deletes list subscriptions from subscribers by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) DeleteSubscriptionsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.DeleteSubscriptionsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting subscriptions by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeLists sets list subscriptions to 'unsubscribed'.
|
||||
func (c *Core) UnsubscribeLists(subIDs, listIDs []int, listUUIDs []string) error {
|
||||
if _, err := c.q.UnsubscribeSubscribersFromLists.Exec(pq.Array(subIDs), pq.Array(listIDs), pq.StringArray(listUUIDs)); err != nil {
|
||||
c.log.Printf("error unsubscribing from lists: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnsubscribeListsByQuery sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) UnsubscribeListsByQuery(searchStr, queryExp string, sourceListIDs, targetListIDs []int, subStatus string) error {
|
||||
if sourceListIDs == nil {
|
||||
sourceListIDs = []int{}
|
||||
}
|
||||
|
||||
err := c.q.ExecSubQueryTpl(searchStr, queryExp, c.q.UnsubscribeSubscribersFromListsByQuery, sourceListIDs, c.db, subStatus, pq.Array(targetListIDs))
|
||||
if err != nil {
|
||||
c.log.Printf("error unsubscribing from lists by query: %v", err)
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUnconfirmedSubscriptions sets list subscriptions to 'unsubscribed' by a given arbitrary query expression.
|
||||
// sourceListIDs is the list of list IDs to filter the subscriber query with.
|
||||
func (c *Core) DeleteUnconfirmedSubscriptions(beforeDate time.Time) (int, error) {
|
||||
res, err := c.q.DeleteUnconfirmedSubscriptions.Exec(beforeDate)
|
||||
if err != nil {
|
||||
c.log.Printf("error deleting unconfirmed subscribers: %v", err)
|
||||
return 0, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
n, _ := res.RowsAffected()
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
null "gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// GetTemplates retrieves all templates.
|
||||
func (c *Core) GetTemplates(status string, noBody bool) ([]models.Template, error) {
|
||||
out := []models.Template{}
|
||||
if err := c.q.GetTemplates.Select(&out, 0, noBody, status); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTemplate retrieves a given template.
|
||||
func (c *Core) GetTemplate(id int, noBody bool) (models.Template, error) {
|
||||
var out []models.Template
|
||||
if err := c.q.GetTemplates.Select(&out, id, noBody, ""); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.templates}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(out) == 0 {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
|
||||
}
|
||||
|
||||
return out[0], nil
|
||||
}
|
||||
|
||||
// CreateTemplate creates a new template.
|
||||
func (c *Core) CreateTemplate(name, typ, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||
var newID int
|
||||
if err := c.q.CreateTemplate.Get(&newID, name, typ, subject, body, bodySource); err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.GetTemplate(newID, false)
|
||||
}
|
||||
|
||||
// UpdateTemplate updates a given template.
|
||||
func (c *Core) UpdateTemplate(id int, name, subject string, body []byte, bodySource null.String) (models.Template, error) {
|
||||
res, err := c.q.UpdateTemplate.Exec(id, name, subject, body, bodySource)
|
||||
if err != nil {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return models.Template{}, echo.NewHTTPError(http.StatusBadRequest,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.template}"))
|
||||
}
|
||||
|
||||
return c.GetTemplate(id, false)
|
||||
}
|
||||
|
||||
// SetDefaultTemplate sets a template as default.
|
||||
func (c *Core) SetDefaultTemplate(id int) error {
|
||||
if _, err := c.q.SetDefaultTemplate.Exec(id); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTemplate deletes a given template.
|
||||
func (c *Core) DeleteTemplate(id int) error {
|
||||
var delID int
|
||||
if err := c.q.DeleteTemplate.Get(&delID, id); err != nil && err != sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorDeleting", "name", "{globals.terms.template}", "error", pqErrMsg(err)))
|
||||
}
|
||||
if delID == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, c.i18n.T("templates.cantDeleteDefault"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/auth"
|
||||
"source.offmarket.win/aleagle/eaglecast/internal/utils"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
func (c *Core) GetUsers() ([]auth.User, error) {
|
||||
out := []auth.User{}
|
||||
if err := c.q.GetUsers.Select(&out); err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.setupUserFields(out), nil
|
||||
}
|
||||
|
||||
// GetUser retrieves a specific user based on any one given identifier.
|
||||
func (c *Core) GetUser(id int, username, email string) (auth.User, error) {
|
||||
var out auth.User
|
||||
if err := c.q.GetUser.Get(&out, id, username, email); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return out, echo.NewHTTPError(http.StatusNotFound,
|
||||
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
|
||||
|
||||
}
|
||||
|
||||
return out, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return c.setupUserFields([]auth.User{out})[0], nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (c *Core) CreateUser(u auth.User) (auth.User, error) {
|
||||
var (
|
||||
apiToken string
|
||||
dbPassword = u.Password
|
||||
)
|
||||
|
||||
// If it's an API user, generate a random token for password
|
||||
// and set the e-mail to default.
|
||||
if u.Type == auth.UserTypeAPI {
|
||||
// Generate a random API token.
|
||||
tk, err := utils.GenerateRandomString(48)
|
||||
if err != nil {
|
||||
return auth.User{}, err
|
||||
}
|
||||
apiToken = tk
|
||||
|
||||
u.Email = null.String{String: u.Username + "@api", Valid: true}
|
||||
u.PasswordLogin = false
|
||||
u.Password = null.String{String: tk, Valid: true}
|
||||
dbPassword = null.String{String: auth.HashAPIToken(tk), Valid: true}
|
||||
}
|
||||
|
||||
var id int
|
||||
if err := c.q.CreateUser.Get(&id, u.Username, u.PasswordLogin, dbPassword, u.Email, u.Name, u.Type, u.UserRoleID, u.ListRoleID, u.Status); err != nil {
|
||||
return auth.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 != auth.UserTypeAPI {
|
||||
u.Password = null.String{Valid: false}
|
||||
}
|
||||
|
||||
out, err := c.GetUser(id, "", "")
|
||||
if u.Type == auth.UserTypeAPI {
|
||||
out.Password = null.String{String: apiToken, Valid: true}
|
||||
}
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
// UpdateUser updates a given user.
|
||||
func (c *Core) UpdateUser(id int, u auth.User) (auth.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 auth.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 auth.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 auth.User) (auth.User, error) {
|
||||
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
|
||||
if err != nil {
|
||||
return auth.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 auth.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
|
||||
}
|
||||
|
||||
// SetTwoFA sets or clears the 2FA configuration for a user.
|
||||
func (c *Core) SetTwoFA(id int, twofaType, twofaKey string) error {
|
||||
if _, err := c.q.SetUserTwoFA.Exec(id, twofaType, twofaKey); err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUserSessions deletes all sessions for a given user ID, optionally
|
||||
// excluding a specific session ID (to keep the current session alive).
|
||||
func (c *Core) DeleteUserSessions(userID int, excludeID string) error {
|
||||
if _, err := c.q.DeleteUserSessions.Exec(strconv.Itoa(userID), excludeID); err != nil {
|
||||
return 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) (auth.User, error) {
|
||||
var out auth.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
|
||||
}
|
||||
|
||||
// setupUserFields prepares and sets up various user fields.
|
||||
func (c *Core) setupUserFields(users []auth.User) []auth.User {
|
||||
for n, u := range users {
|
||||
u := u
|
||||
|
||||
if u.Password.String != "" {
|
||||
u.HasPassword = true
|
||||
u.PasswordLogin = true
|
||||
}
|
||||
|
||||
if u.Type == auth.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 []auth.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 = &auth.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{})
|
||||
|
||||
for _, perm := range p.Permissions {
|
||||
u.ListPermissionsMap[p.ID][perm] = struct{}{}
|
||||
|
||||
// List IDs with get / manage permissions.
|
||||
if perm == auth.PermListGet {
|
||||
u.GetListIDs = append(u.GetListIDs, p.ID)
|
||||
}
|
||||
if perm == auth.PermListManage {
|
||||
u.ManageListIDs = append(u.ManageListIDs, p.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users[n] = u
|
||||
}
|
||||
|
||||
return users
|
||||
}
|
||||
Reference in New Issue
Block a user