listmonk/subscribers.go

346 lines
9.5 KiB
Go

package main
// !!!!!!!!!!! TODO
// For non-flat JSON attribs, show the advanced editor instead of the key-value editor
import (
"context"
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/asaskevich/govalidator"
"github.com/knadh/listmonk/models"
"github.com/knadh/listmonk/subimporter"
"github.com/labstack/echo"
"github.com/lib/pq"
uuid "github.com/satori/go.uuid"
)
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 queryAddResp struct {
Count int64 `json:"count"`
}
type queryAddReq struct {
Query string `json:"query"`
SourceList int `json:"source_list"`
TargetLists pq.Int64Array `json:"target_lists"`
}
var (
jsonMap = []byte("{}")
dummySubscriber = models.Subscriber{
Email: "dummy@listmonk.app",
Name: "Dummy Subscriber",
UUID: "00000000-0000-0000-0000-000000000000",
}
)
// 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"))
out models.Subscribers
)
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
}
err := app.Queries.GetSubscriber.Select(&out, id, nil)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber: %s", pqErrMsg(err)))
} else if len(out) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Subscriber not found.")
}
out.LoadLists(app.Queries.GetSubscriberLists)
return c.JSON(http.StatusOK, okResp{out[0]})
}
// handleQuerySubscribers handles querying subscribers based on arbitrary conditions in SQL.
func handleQuerySubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
pg = getPagination(c.QueryParams())
// Limit the subscribers to a particular list?
listID, _ = strconv.Atoi(c.FormValue("list_id"))
hasList bool
// The "WHERE ?" bit.
query = c.FormValue("query")
out subsWrap
)
if listID < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `list_id`.")
} else if listID > 0 {
hasList = true
}
// There's an arbitrary query condition from the frontend.
cond := ""
if query != "" {
cond = " AND " + query
}
// The SQL queries to be executed are different for global subscribers
// and subscribers belonging to a specific list.
var (
stmt = ""
stmtCount = ""
)
if hasList {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersByList,
listID, cond, pg.Offset, pg.Limit)
stmtCount = fmt.Sprintf(app.Queries.QuerySubscribersByListCount,
listID, cond)
} else {
stmt = fmt.Sprintf(app.Queries.QuerySubscribers,
cond, pg.Offset, pg.Limit)
stmtCount = fmt.Sprintf(app.Queries.QuerySubscribersCount, cond)
}
// Create a readonly transaction to prevent mutations.
tx, err := app.DB.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query: %v", pqErrMsg(err)))
}
// Run the actual query.
if err := tx.Select(&out.Results, stmt); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying subscribers: %v", pqErrMsg(err)))
}
// Run the query count.
if err := tx.Get(&out.Total, stmtCount); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error running count query: %v", pqErrMsg(err)))
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error in subscriber query transaction: %v", pqErrMsg(err)))
}
// Lazy load lists for each subscriber.
if err := out.Results.LoadLists(app.Queries.GetSubscriberLists); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching subscriber lists: %v", pqErrMsg(err)))
}
if len(out.Results) == 0 {
out.Results = make(models.Subscribers, 0)
return c.JSON(http.StatusOK, okResp{out})
}
// Meta.
out.Query = query
out.Page = pg.Page
out.PerPage = pg.PerPage
return c.JSON(http.StatusOK, okResp{out})
}
// handleCreateSubscriber handles subscriber creation.
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
} else if err := subimporter.ValidateFields(req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
// Insert and read ID.
var newID int
err := app.Queries.InsertSubscriber.Get(&newID,
uuid.NewV4(),
strings.ToLower(strings.TrimSpace(req.Email)),
strings.TrimSpace(req.Name),
req.Attribs,
req.Lists)
if err != nil {
if pqErr, ok := err.(*pq.Error); ok && pqErr.Constraint == "subscribers_email_key" {
return echo.NewHTTPError(http.StatusBadRequest, "The e-mail already exists.")
}
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error creating subscriber: %v", err))
}
// Hand over to the GET handler to return the last insertion.
c.SetParamNames("id")
c.SetParamValues(fmt.Sprintf("%d", newID))
return c.JSON(http.StatusOK, handleGetSubscriber(c))
}
// handleUpdateSubscriber handles subscriber modification.
func handleUpdateSubscriber(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
req subimporter.SubReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return err
}
if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
if req.Email != "" && !govalidator.IsEmail(req.Email) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `email`.")
}
if req.Name != "" && !govalidator.IsByteLength(req.Name, 1, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid length for `name`.")
}
_, err := app.Queries.UpdateSubscriber.Exec(req.ID,
strings.ToLower(strings.TrimSpace(req.Email)),
strings.TrimSpace(req.Name),
req.Status,
req.Attribs,
req.Lists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Update failed: %v", pqErrMsg(err)))
}
return handleGetSubscriber(c)
}
// handleDeleteSubscribers handles subscriber deletion,
// either a single one (ID in the URI), or a list.
func handleDeleteSubscribers(c echo.Context) error {
var (
app = c.Get("app").(*App)
id, _ = strconv.ParseInt(c.Param("id"), 10, 64)
ids pq.Int64Array
)
// Read the list IDs if they were sent in the body.
c.Bind(&ids)
if id < 1 && len(ids) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
if id > 0 {
ids = append(ids, id)
}
if _, err := app.Queries.DeleteSubscribers.Exec(ids); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Delete failed: %v", err))
}
return c.JSON(http.StatusOK, okResp{true})
}
// handleQuerySubscribersIntoLists handles querying subscribers based on arbitrary conditions in SQL
// and adding them to given lists.
func handleQuerySubscribersIntoLists(c echo.Context) error {
var (
app = c.Get("app").(*App)
req queryAddReq
)
// Get and validate fields.
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("Error parsing request: %v", err))
}
if len(req.TargetLists) < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `target_lists`.")
}
if req.SourceList < 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid `source_list`.")
}
if req.Query == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber `query`.")
}
cond := " AND " + req.Query
// The SQL queries to be executed are different for global subscribers
// and subscribers belonging to a specific list.
var (
stmt = ""
stmtDry = ""
)
if req.SourceList > 0 {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersByList, req.SourceList, cond)
stmtDry = fmt.Sprintf(app.Queries.QuerySubscribersByList, req.SourceList, cond, 0, 1)
} else {
stmt = fmt.Sprintf(app.Queries.QuerySubscribersIntoLists, cond)
stmtDry = fmt.Sprintf(app.Queries.QuerySubscribers, cond, 0, 1)
}
// Create a readonly transaction to prevent mutations.
// This is used to dry-run the arbitrary query before it's used to
// insert subscriptions.
tx, err := app.DB.BeginTxx(context.Background(), &sql.TxOptions{ReadOnly: true})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query (dry-run): %v", pqErrMsg(err)))
}
// Perform the dry run.
if _, err := tx.Exec(stmtDry); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error querying (dry-run) subscribers: %v", pqErrMsg(err)))
}
if err := tx.Commit(); err != nil {
tx.Rollback()
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error in subscriber dry-run query transaction: %v", pqErrMsg(err)))
}
// Prepare the query.
q, err := app.DB.Preparex(stmt)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error preparing query: %v", pqErrMsg(err)))
}
// Run the query.
res, err := q.Exec(req.TargetLists)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error adding subscribers to lists: %v", pqErrMsg(err)))
}
num, _ := res.RowsAffected()
return c.JSON(http.StatusOK, okResp{queryAddResp{num}})
}