EagleCast
publish-github-pages / deploy (push) Waiting to run

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+112
View File
@@ -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
}
+511
View File
@@ -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
}
+209
View File
@@ -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
}
+34
View File
@@ -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
}
+217
View File
@@ -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
}
+102
View File
@@ -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
}
+180
View File
@@ -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
}
+60
View File
@@ -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
}
+671
View File
@@ -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)
}
}
}
}
}
+122
View File
@@ -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
}
+88
View File
@@ -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
}
+243
View File
@@ -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
}