123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863 |
- package main
- import (
- "context"
- "database/sql"
- "encoding/csv"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "github.com/gofrs/uuid"
- "github.com/knadh/listmonk/internal/subimporter"
- "github.com/knadh/listmonk/models"
- "github.com/labstack/echo"
- "github.com/lib/pq"
- )
- const (
- dummyUUID = "00000000-0000-0000-0000-000000000000"
- )
- // subQueryReq is a "catch all" struct for reading various
- // subscriber related requests.
- type subQueryReq struct {
- Query string `json:"query"`
- ListIDs pq.Int64Array `json:"list_ids"`
- TargetListIDs pq.Int64Array `json:"target_list_ids"`
- SubscriberIDs pq.Int64Array `json:"ids"`
- Action string `json:"action"`
- }
- type subsWrap struct {
- Results models.Subscribers `json:"results"`
- Query string `json:"query"`
- Total int `json:"total"`
- PerPage int `json:"per_page"`
- Page int `json:"page"`
- }
- type subUpdateReq struct {
- models.Subscriber
- RawAttribs json.RawMessage `json:"attribs"`
- Lists pq.Int64Array `json:"lists"`
- ListUUIDs pq.StringArray `json:"list_uuids"`
- PreconfirmSubs bool `json:"preconfirm_subscriptions"`
- }
- // subProfileData represents a subscriber's collated data in JSON
- // for export.
- type subProfileData struct {
- Email string `db:"email" json:"-"`
- Profile json.RawMessage `db:"profile" json:"profile,omitempty"`
- Subscriptions json.RawMessage `db:"subscriptions" json:"subscriptions,omitempty"`
- CampaignViews json.RawMessage `db:"campaign_views" json:"campaign_views,omitempty"`
- LinkClicks json.RawMessage `db:"link_clicks" json:"link_clicks,omitempty"`
- }
- // subOptin contains the data that's passed to the double opt-in e-mail template.
- type subOptin struct {
- *models.Subscriber
- OptinURL string
- Lists []models.List
- }
- var (
- dummySubscriber = models.Subscriber{
- Email: "demo@listmonk.app",
- Name: "Demo Subscriber",
- UUID: dummyUUID,
- }
- subQuerySortFields = []string{"email", "name", "created_at", "updated_at"}
- errSubscriberExists = errors.New("subscriber already exists")
- )
- // handleGetSubscriber handles the retrieval of a single subscriber by ID.
- func handleGetSubscriber(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- id, _ = strconv.Atoi(c.Param("id"))
- )
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- sub, err := getSubscriber(id, "", "", app)
- if err != nil {
- return err
- }
- return c.JSON(http.StatusOK, okResp{sub})
- }
- // handleQuerySubscribers handles querying subscribers based on an arbitrary SQL expression.
- func handleQuerySubscribers(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pg = getPagination(c.QueryParams(), 30)
- // Limit the subscribers to a particular list?
- listID, _ = strconv.Atoi(c.FormValue("list_id"))
- // The "WHERE ?" bit.
- query = sanitizeSQLExp(c.FormValue("query"))
- orderBy = c.FormValue("order_by")
- order = c.FormValue("order")
- out = subsWrap{Results: make([]models.Subscriber, 0, 1)}
- )
- listIDs := pq.Int64Array{}
- if listID < 0 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
- } else if listID > 0 {
- listIDs = append(listIDs, int64(listID))
- }
- // There's an arbitrary query condition.
- cond := ""
- if query != "" {
- cond = " AND " + query
- }
- // Sort params.
- if !strSliceContains(orderBy, subQuerySortFields) {
- orderBy = "subscribers.id"
- }
- if order != sortAsc && order != sortDesc {
- order = sortDesc
- }
- // 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 := fmt.Sprintf(app.queries.QuerySubscribersCount, cond)
- tx, err := app.db.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
- if err != nil {
- app.log.Printf("error preparing subscriber query: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
- }
- defer tx.Rollback()
- // Execute the readonly query and get the count of results.
- var total = 0
- if err := tx.Get(&total, stmt, listIDs); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- // No results.
- if total == 0 {
- return c.JSON(http.StatusOK, okResp{out})
- }
- // Run the query again and fetch the actual data. stmt is the raw SQL query.
- stmt = fmt.Sprintf(app.queries.QuerySubscribers, cond, orderBy, order)
- if err := tx.Select(&out.Results, stmt, listIDs, pg.Offset, pg.Limit); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- // Lazy load lists for each subscriber.
- if err := out.Results.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
- app.log.Printf("error fetching subscriber lists: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- out.Query = query
- if len(out.Results) == 0 {
- out.Results = make(models.Subscribers, 0)
- return c.JSON(http.StatusOK, okResp{out})
- }
- // Meta.
- out.Total = total
- out.Page = pg.Page
- out.PerPage = pg.PerPage
- return c.JSON(http.StatusOK, okResp{out})
- }
- // handleExportSubscribers handles querying subscribers based on an arbitrary SQL expression.
- func handleExportSubscribers(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- // Limit the subscribers to a particular list?
- listID, _ = strconv.Atoi(c.FormValue("list_id"))
- // The "WHERE ?" bit.
- query = sanitizeSQLExp(c.FormValue("query"))
- )
- listIDs := pq.Int64Array{}
- if listID < 0 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.errorID"))
- } else if listID > 0 {
- listIDs = append(listIDs, int64(listID))
- }
- // There's an arbitrary query condition.
- cond := ""
- if query != "" {
- cond = " AND " + query
- }
- stmt := fmt.Sprintf(app.queries.QuerySubscribersForExport, cond)
- // Verify that the arbitrary SQL search expression is read only.
- if cond != "" {
- tx, err := app.db.Unsafe().BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
- if err != nil {
- app.log.Printf("error preparing subscriber query: %v", err)
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
- }
- defer tx.Rollback()
- if _, err := tx.Query(stmt, nil, 0, 1); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
- }
- }
- // Prepare the actual query statement.
- tx, err := db.Preparex(stmt)
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("subscribers.errorPreparingQuery", "error", pqErrMsg(err)))
- }
- // Run the query until all rows are exhausted.
- var (
- id = 0
- h = c.Response().Header()
- wr = csv.NewWriter(c.Response())
- )
- h.Set(echo.HeaderContentType, echo.MIMEOctetStream)
- h.Set("Content-type", "text/csv")
- h.Set(echo.HeaderContentDisposition, "attachment; filename="+"subscribers.csv")
- h.Set("Content-Transfer-Encoding", "binary")
- h.Set("Cache-Control", "no-cache")
- wr.Write([]string{"uuid", "email", "name", "attributes", "status", "created_at", "updated_at"})
- loop:
- for {
- var out []models.SubscriberExport
- if err := tx.Select(&out, listIDs, id, app.constants.DBBatchSize); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- if len(out) == 0 {
- break loop
- }
- for _, r := range out {
- if err = wr.Write([]string{r.UUID, r.Email, r.Name, r.Attribs, r.Status,
- r.CreatedAt.Time.String(), r.UpdatedAt.Time.String()}); err != nil {
- app.log.Printf("error streaming CSV export: %v", err)
- break loop
- }
- }
- wr.Flush()
- id = out[len(out)-1].ID
- }
- return nil
- }
- // handleCreateSubscriber handles the creation of a new subscriber.
- func handleCreateSubscriber(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- req subimporter.SubReq
- )
- // Get and validate fields.
- if err := c.Bind(&req); err != nil {
- return err
- }
- r, err := app.importer.ValidateFields(req)
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, err.Error())
- } else {
- req = r
- }
- // Insert the subscriber into the DB.
- sub, isNew, _, err := insertSubscriber(req, app)
- if err != nil {
- return err
- }
- if !isNew {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.emailExists"))
- }
- return c.JSON(http.StatusOK, okResp{sub})
- }
- // handleUpdateSubscriber handles modification of a subscriber.
- func handleUpdateSubscriber(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
- req subUpdateReq
- )
- // Get and validate fields.
- if err := c.Bind(&req); err != nil {
- return err
- }
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- if em, err := app.importer.SanitizeEmail(req.Email); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest, err.Error())
- } else {
- req.Email = em
- }
- if req.Name != "" && !strHasLen(req.Name, 1, stdInputMaxLen) {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
- }
- // If there's an attribs value, validate it.
- if len(req.RawAttribs) > 0 {
- var a models.SubscriberAttribs
- if err := json.Unmarshal(req.RawAttribs, &a); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorUpdating",
- "name", "{globals.terms.subscriber}", "error", err.Error()))
- }
- }
- subStatus := models.SubscriptionStatusUnconfirmed
- if req.PreconfirmSubs {
- subStatus = models.SubscriptionStatusConfirmed
- }
- _, err := app.queries.UpdateSubscriber.Exec(id,
- strings.ToLower(strings.TrimSpace(req.Email)),
- strings.TrimSpace(req.Name),
- req.Status,
- req.RawAttribs,
- req.Lists,
- subStatus)
- if err != nil {
- app.log.Printf("error updating subscriber: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorUpdating",
- "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
- }
- // Send a confirmation e-mail (if there are any double opt-in lists).
- sub, err := getSubscriber(int(id), "", "", app)
- if err != nil {
- return err
- }
- if !req.PreconfirmSubs && app.constants.SendOptinConfirmation {
- _, _ = sendOptinConfirmation(sub, []int64(req.Lists), app)
- }
- return c.JSON(http.StatusOK, okResp{sub})
- }
- // handleGetSubscriberSendOptin sends an optin confirmation e-mail to a subscriber.
- func handleSubscriberSendOptin(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- id, _ = strconv.Atoi(c.Param("id"))
- )
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- // Fetch the subscriber.
- out, err := getSubscriber(id, "", "", app)
- if err != nil {
- app.log.Printf("error fetching subscriber: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- if _, err := sendOptinConfirmation(out, nil, app); err != nil {
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.T("subscribers.errorSendingOptin"))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleBlocklistSubscribers handles the blocklisting of one or more subscribers.
- // It takes either an ID in the URI, or a list of IDs in the request body.
- func handleBlocklistSubscribers(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pID = c.Param("id")
- IDs pq.Int64Array
- )
- // Is it a /:id call?
- if pID != "" {
- id, _ := strconv.ParseInt(pID, 10, 64)
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- IDs = append(IDs, id)
- } else {
- // Multiple IDs.
- var req subQueryReq
- if err := c.Bind(&req); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
- }
- if len(req.SubscriberIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest,
- "No IDs given.")
- }
- IDs = req.SubscriberIDs
- }
- if _, err := app.queries.BlocklistSubscribers.Exec(IDs); err != nil {
- app.log.Printf("error blocklisting subscribers: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("subscribers.errorBlocklisting", "error", err.Error()))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleManageSubscriberLists handles bulk addition or removal of subscribers
- // from or to one or more target lists.
- // It takes either an ID in the URI, or a list of IDs in the request body.
- func handleManageSubscriberLists(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pID = c.Param("id")
- IDs pq.Int64Array
- )
- // Is it a /:id call?
- if pID != "" {
- id, _ := strconv.ParseInt(pID, 10, 64)
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- IDs = append(IDs, id)
- }
- var req subQueryReq
- if err := c.Bind(&req); err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
- }
- if len(req.SubscriberIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoIDs"))
- }
- if len(IDs) == 0 {
- IDs = req.SubscriberIDs
- }
- if len(req.TargetListIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.errorNoListsGiven"))
- }
- // Action.
- var err error
- switch req.Action {
- case "add":
- _, err = app.queries.AddSubscribersToLists.Exec(IDs, req.TargetListIDs)
- case "remove":
- _, err = app.queries.DeleteSubscriptions.Exec(IDs, req.TargetListIDs)
- case "unsubscribe":
- _, err = app.queries.UnsubscribeSubscribersFromLists.Exec(IDs, req.TargetListIDs)
- default:
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
- }
- if err != nil {
- app.log.Printf("error updating subscriptions: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorUpdating",
- "name", "{globals.terms.subscribers}", "error", err.Error()))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleDeleteSubscribers handles subscriber deletion.
- // It takes either an ID in the URI, or a list of IDs in the request body.
- func handleDeleteSubscribers(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pID = c.Param("id")
- IDs pq.Int64Array
- )
- // Is it an /:id call?
- if pID != "" {
- id, _ := strconv.ParseInt(pID, 10, 64)
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- IDs = append(IDs, id)
- } else {
- // Multiple IDs.
- i, err := parseStringIDs(c.Request().URL.Query()["id"])
- if err != nil {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("globals.messages.errorInvalidIDs", "error", err.Error()))
- }
- if len(i) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("subscribers.errorNoIDs", "error", err.Error()))
- }
- IDs = i
- }
- if _, err := app.queries.DeleteSubscribers.Exec(IDs, nil); err != nil {
- app.log.Printf("error deleting subscribers: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorDeleting",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleDeleteSubscribersByQuery bulk deletes based on an
- // arbitrary SQL expression.
- func handleDeleteSubscribersByQuery(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- req subQueryReq
- )
- if err := c.Bind(&req); err != nil {
- return err
- }
- err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
- app.queries.DeleteSubscribersByQuery,
- req.ListIDs, app.db)
- if err != nil {
- app.log.Printf("error deleting subscribers: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorDeleting",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleBlocklistSubscribersByQuery bulk blocklists subscribers
- // based on an arbitrary SQL expression.
- func handleBlocklistSubscribersByQuery(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- req subQueryReq
- )
- if err := c.Bind(&req); err != nil {
- return err
- }
- err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
- app.queries.BlocklistSubscribersByQuery,
- req.ListIDs, app.db)
- if err != nil {
- app.log.Printf("error blocklisting subscribers: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("subscribers.errorBlocklisting", "error", pqErrMsg(err)))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleManageSubscriberListsByQuery bulk adds/removes/unsubscribers subscribers
- // from one or more lists based on an arbitrary SQL expression.
- func handleManageSubscriberListsByQuery(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- req subQueryReq
- )
- if err := c.Bind(&req); err != nil {
- return err
- }
- if len(req.TargetListIDs) == 0 {
- return echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.T("subscribers.errorNoListsGiven"))
- }
- // Action.
- var stmt string
- switch req.Action {
- case "add":
- stmt = app.queries.AddSubscribersToListsByQuery
- case "remove":
- stmt = app.queries.DeleteSubscriptionsByQuery
- case "unsubscribe":
- stmt = app.queries.UnsubscribeSubscribersFromListsByQuery
- default:
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidAction"))
- }
- err := app.queries.execSubscriberQueryTpl(sanitizeSQLExp(req.Query),
- stmt, req.ListIDs, app.db, req.TargetListIDs)
- if err != nil {
- app.log.Printf("error updating subscriptions: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorUpdating",
- "name", "{globals.terms.subscribers}", "error", pqErrMsg(err)))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleDeleteSubscriberBounces deletes all the bounces on a subscriber.
- func handleDeleteSubscriberBounces(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pID = c.Param("id")
- )
- id, _ := strconv.ParseInt(pID, 10, 64)
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- if _, err := app.queries.DeleteBouncesBySubscriber.Exec(id, nil); err != nil {
- app.log.Printf("error deleting bounces: %v", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorDeleting",
- "name", "{globals.terms.bounces}", "error", pqErrMsg(err)))
- }
- return c.JSON(http.StatusOK, okResp{true})
- }
- // handleExportSubscriberData pulls the subscriber's profile,
- // list subscriptions, campaign views and clicks and produces
- // a JSON report. This is a privacy feature and depends on the
- // configuration in app.Constants.Privacy.
- func handleExportSubscriberData(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- pID = c.Param("id")
- )
- id, _ := strconv.ParseInt(pID, 10, 64)
- if id < 1 {
- return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.invalidID"))
- }
- // 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".
- _, b, err := exportSubscriberData(id, "", app.constants.Privacy.Exportable, app)
- if err != nil {
- app.log.Printf("error exporting subscriber data: %s", err)
- return echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscribers}", "error", err.Error()))
- }
- c.Response().Header().Set("Cache-Control", "no-cache")
- c.Response().Header().Set("Content-Disposition", `attachment; filename="data.json"`)
- return c.Blob(http.StatusOK, "application/json", b)
- }
- // 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.
- func insertSubscriber(req subimporter.SubReq, app *App) (models.Subscriber, bool, bool, error) {
- uu, err := uuid.NewV4()
- if err != nil {
- return req.Subscriber, false, false, err
- }
- req.UUID = uu.String()
- var (
- isNew = true
- subStatus = models.SubscriptionStatusUnconfirmed
- )
- if req.PreconfirmSubs {
- subStatus = models.SubscriptionStatusConfirmed
- }
- if err = app.queries.InsertSubscriber.Get(&req.ID,
- req.UUID,
- req.Email,
- strings.TrimSpace(req.Name),
- req.Status,
- req.Attribs,
- req.Lists,
- req.ListUUIDs,
- subStatus); err != nil {
- if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
- isNew = false
- } else {
- // return req.Subscriber, errSubscriberExists
- app.log.Printf("error inserting subscriber: %v", err)
- return req.Subscriber, false, false, echo.NewHTTPError(http.StatusInternalServerError,
- app.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.
- sub, err := getSubscriber(req.ID, "", strings.ToLower(req.Email), app)
- if err != nil {
- return sub, false, false, err
- }
- hasOptin := false
- if !req.PreconfirmSubs && app.constants.SendOptinConfirmation {
- // Send a confirmation e-mail (if there are any double opt-in lists).
- num, _ := sendOptinConfirmation(sub, []int64(req.Lists), app)
- hasOptin = num > 0
- }
- return sub, isNew, hasOptin, nil
- }
- // getSubscriber gets a single subscriber by ID, uuid, or e-mail in that order.
- // Only one of these params should have a value.
- func getSubscriber(id int, uuid, email string, app *App) (models.Subscriber, error) {
- var out models.Subscribers
- if err := app.queries.GetSubscriber.Select(&out, id, uuid, email); err != nil {
- app.log.Printf("error fetching subscriber: %v", err)
- return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.subscriber}", "error", pqErrMsg(err)))
- }
- if len(out) == 0 {
- return models.Subscriber{}, echo.NewHTTPError(http.StatusBadRequest,
- app.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.subscriber}"))
- }
- if err := out.LoadLists(app.queries.GetSubscriberListsLazy); err != nil {
- app.log.Printf("error loading subscriber lists: %v", err)
- return models.Subscriber{}, echo.NewHTTPError(http.StatusInternalServerError,
- app.i18n.Ts("globals.messages.errorFetching",
- "name", "{globals.terms.lists}", "error", pqErrMsg(err)))
- }
- return out[0], nil
- }
- // exportSubscriberData collates the data of a subscriber including profile,
- // subscriptions, campaign_views, link_clicks (if they're enabled in the config)
- // and returns a formatted, indented JSON payload. Either takes a numeric id
- // and an empty subUUID or takes 0 and a string subUUID.
- func exportSubscriberData(id int64, subUUID string, exportables map[string]bool, app *App) (subProfileData, []byte, error) {
- // 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".
- var (
- data subProfileData
- uu interface{}
- )
- // UUID should be a valid value or a nil.
- if subUUID != "" {
- uu = subUUID
- }
- if err := app.queries.ExportSubscriberData.Get(&data, id, uu); err != nil {
- app.log.Printf("error fetching subscriber export data: %v", err)
- return data, nil, err
- }
- // Filter out the non-exportable items.
- if _, ok := exportables["profile"]; !ok {
- data.Profile = nil
- }
- if _, ok := exportables["subscriptions"]; !ok {
- data.Subscriptions = nil
- }
- if _, ok := exportables["campaign_views"]; !ok {
- data.CampaignViews = nil
- }
- if _, ok := exportables["link_clicks"]; !ok {
- data.LinkClicks = nil
- }
- // Marshal the data into an indented payload.
- b, err := json.MarshalIndent(data, "", " ")
- if err != nil {
- app.log.Printf("error marshalling subscriber export data: %v", err)
- return data, nil, err
- }
- return data, b, nil
- }
- // sendOptinConfirmation sends a double opt-in confirmation e-mail to a subscriber
- // if at least one of the given listIDs is set to optin=double. It returns the number of
- // opt-in lists that were found.
- func sendOptinConfirmation(sub models.Subscriber, listIDs []int64, app *App) (int, error) {
- var lists []models.List
- // Fetch double opt-in lists from the given list IDs.
- // Get the list of subscription lists where the subscriber hasn't confirmed.
- if err := app.queries.GetSubscriberLists.Select(&lists, sub.ID, nil,
- pq.Int64Array(listIDs), nil, models.SubscriptionStatusUnconfirmed, models.ListOptinDouble); err != nil {
- app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
- return 0, err
- }
- // None.
- if len(lists) == 0 {
- return 0, nil
- }
- var (
- out = subOptin{Subscriber: &sub, Lists: lists}
- qListIDs = url.Values{}
- )
- // Construct the opt-in URL with list IDs.
- for _, l := range out.Lists {
- qListIDs.Add("l", l.UUID)
- }
- out.OptinURL = fmt.Sprintf(app.constants.OptinURL, sub.UUID, qListIDs.Encode())
- // Send the e-mail.
- if err := app.sendNotification([]string{sub.Email},
- app.i18n.T("subscribers.optinSubject"), notifSubscriberOptin, out); err != nil {
- app.log.Printf("error sending opt-in e-mail: %s", err)
- return 0, err
- }
- return len(lists), nil
- }
- // 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
- }
|