listmonk/cmd/handlers.go
Kailash Nadh b5cd9498b1 Refactore all CRUD functions to a new core package.
This is a long pending refactor. All the DB, query, CRUD, and related
logic scattered across HTTP handlers are now moved into a central
`core` package with clean, abstracted methods, decoupling HTTP
handlers from executing direct DB queries and other business logic.

eg: `core.CreateList()`, `core.GetLists()` etc.

- Remove obsolete subscriber methods.
- Move optin hook queries to core.
- Move campaign methods to `core`.
- Move all campaign methods to `core`.
- Move public page functions to `core`.
- Move all template functions to `core`.
- Move media and settings function to `core`.
- Move handler middleware functions to `core`.
- Move all bounce functions to `core`.
- Move all dashboard functions to `core`.
- Fix GetLists() not honouring type
- Fix unwrapped JSON responses.
- Clean up obsolete pre-core util function.
- Replace SQL array null check with cardinality check.
- Fix missing validations in `core` queries.
- Remove superfluous deps on internal `subimporter`.
- Add dashboard functions to `core`.
- Fix broken domain ban check.
- Fix broken subscriber check middleware.
- Remove redundant error handling.
- Remove obsolete functions.
- Remove obsolete structs.
- Remove obsolete queries and DB functions.
- Document the `core` package.
2022-05-03 10:50:29 +05:30

321 lines
11 KiB
Go

package main
import (
"crypto/subtle"
"net/http"
"net/url"
"path"
"regexp"
"strconv"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
const (
// stdInputMaxLen is the maximum allowed length for a standard input field.
stdInputMaxLen = 200
sortAsc = "asc"
sortDesc = "desc"
)
type okResp struct {
Data interface{} `json:"data"`
}
// pagination represents a query's pagination (limit, offset) related values.
type pagination struct {
PerPage int `json:"per_page"`
Page int `json:"page"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
var (
reUUID = regexp.MustCompile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9\\-]")
)
// registerHandlers registers HTTP handlers.
func initHTTPHandlers(e *echo.Echo, app *App) {
// Group of private handlers with BasicAuth.
var g *echo.Group
if len(app.constants.AdminUsername) == 0 ||
len(app.constants.AdminPassword) == 0 {
g = e.Group("")
} else {
g = e.Group("", middleware.BasicAuth(basicAuth))
}
// Admin JS app views.
// /admin/static/* file server is registered in initHTTPServer().
e.GET("/", func(c echo.Context) error {
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
})
g.GET(path.Join(adminRoot, ""), handleAdminPage)
g.GET(path.Join(adminRoot, "/custom.css"), serveCustomApperance("admin.custom_css"))
g.GET(path.Join(adminRoot, "/custom.js"), serveCustomApperance("admin.custom_js"))
g.GET(path.Join(adminRoot, "/*"), handleAdminPage)
// API endpoints.
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config", handleGetServerConfig)
g.GET("/api/lang/:lang", handleGetI18nLang)
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
g.GET("/api/settings", handleGetSettings)
g.PUT("/api/settings", handleUpdateSettings)
g.POST("/api/admin/reload", handleReloadApp)
g.GET("/api/logs", handleGetLogs)
g.GET("/api/subscribers/:id", handleGetSubscriber)
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces)
g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces)
g.POST("/api/subscribers", handleCreateSubscriber)
g.PUT("/api/subscribers/:id", handleUpdateSubscriber)
g.POST("/api/subscribers/:id/optin", handleSubscriberSendOptin)
g.PUT("/api/subscribers/blocklist", handleBlocklistSubscribers)
g.PUT("/api/subscribers/:id/blocklist", handleBlocklistSubscribers)
g.PUT("/api/subscribers/lists/:id", handleManageSubscriberLists)
g.PUT("/api/subscribers/lists", handleManageSubscriberLists)
g.DELETE("/api/subscribers/:id", handleDeleteSubscribers)
g.DELETE("/api/subscribers", handleDeleteSubscribers)
g.GET("/api/bounces", handleGetBounces)
g.GET("/api/bounces/:id", handleGetBounces)
g.DELETE("/api/bounces", handleDeleteBounces)
g.DELETE("/api/bounces/:id", handleDeleteBounces)
// Subscriber operations based on arbitrary SQL queries.
// These aren't very REST-like.
g.POST("/api/subscribers/query/delete", handleDeleteSubscribersByQuery)
g.PUT("/api/subscribers/query/blocklist", handleBlocklistSubscribersByQuery)
g.PUT("/api/subscribers/query/lists", handleManageSubscriberListsByQuery)
g.GET("/api/subscribers", handleQuerySubscribers)
g.GET("/api/subscribers/export",
middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers))
g.GET("/api/import/subscribers", handleGetImportSubscribers)
g.GET("/api/import/subscribers/logs", handleGetImportSubscriberStats)
g.POST("/api/import/subscribers", handleImportSubscribers)
g.DELETE("/api/import/subscribers", handleStopImportSubscribers)
g.GET("/api/lists", handleGetLists)
g.GET("/api/lists/:id", handleGetLists)
g.POST("/api/lists", handleCreateList)
g.PUT("/api/lists/:id", handleUpdateList)
g.DELETE("/api/lists/:id", handleDeleteLists)
g.GET("/api/campaigns", handleGetCampaigns)
g.GET("/api/campaigns/running/stats", handleGetRunningCampaignStats)
g.GET("/api/campaigns/:id", handleGetCampaign)
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
g.POST("/api/campaigns/:id/content", handleCampaignContent)
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
g.POST("/api/campaigns/:id/test", handleTestCampaign)
g.POST("/api/campaigns", handleCreateCampaign)
g.PUT("/api/campaigns/:id", handleUpdateCampaign)
g.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
g.GET("/api/media", handleGetMedia)
g.GET("/api/media/:id", handleGetMedia)
g.POST("/api/media", handleUploadMedia)
g.DELETE("/api/media/:id", handleDeleteMedia)
g.GET("/api/templates", handleGetTemplates)
g.GET("/api/templates/:id", handleGetTemplates)
g.GET("/api/templates/:id/preview", handlePreviewTemplate)
g.POST("/api/templates/preview", handlePreviewTemplate)
g.POST("/api/templates", handleCreateTemplate)
g.PUT("/api/templates/:id", handleUpdateTemplate)
g.PUT("/api/templates/:id/default", handleTemplateSetDefault)
g.DELETE("/api/templates/:id", handleDeleteTemplate)
if app.constants.BounceWebhooksEnabled {
// Private authenticated bounce endpoint.
g.POST("/webhooks/bounce", handleBounceWebhook)
// Public bounce endpoints for webservices like SES.
e.POST("/webhooks/service/:service", handleBounceWebhook)
}
// /public/static/* file server is registered in initHTTPServer().
// Public subscriber facing views.
e.GET("/subscription/form", handleSubscriptionFormPage)
e.POST("/subscription/form", handleSubscriptionForm)
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID")))
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPage),
"campUUID", "subUUID"))
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
"subUUID"))
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
"subUUID"))
e.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect,
"linkUUID", "campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage,
"campUUID", "subUUID")))
e.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView,
"campUUID", "subUUID")))
e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))
// Public health API endpoint.
e.GET("/health", handleHealthCheck)
}
// handleAdminPage is the root handler that renders the Javascript admin frontend.
func handleAdminPage(c echo.Context) error {
app := c.Get("app").(*App)
b, err := app.fs.Read(path.Join(adminRoot, "/index.html"))
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return c.HTMLBlob(http.StatusOK, b)
}
// handleHealthCheck is a healthcheck endpoint that returns a 200 response.
func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// serveCustomApperance serves the given custom CSS/JS appearance blob
// meant for customizing public and admin pages from the admin settings UI.
func serveCustomApperance(name string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
app = c.Get("app").(*App)
out []byte
hdr string
)
switch name {
case "admin.custom_css":
out = app.constants.Appearance.AdminCSS
hdr = "text/css; charset=utf-8"
case "admin.custom_js":
out = app.constants.Appearance.AdminJS
hdr = "application/javascript; charset=utf-8"
case "public.custom_css":
out = app.constants.Appearance.PublicCSS
hdr = "text/css; charset=utf-8"
case "public.custom_js":
out = app.constants.Appearance.PublicJS
hdr = "application/javascript; charset=utf-8"
}
return c.Blob(http.StatusOK, hdr, out)
}
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)
// Auth is disabled.
if len(app.constants.AdminUsername) == 0 &&
len(app.constants.AdminPassword) == 0 {
return true, nil
}
if subtle.ConstantTimeCompare([]byte(username), app.constants.AdminUsername) == 1 &&
subtle.ConstantTimeCompare([]byte(password), app.constants.AdminPassword) == 1 {
return true, nil
}
return false, nil
}
// validateUUID middleware validates the UUID string format for a given set of params.
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
app := c.Get("app").(*App)
for _, p := range params {
if !reUUID.MatchString(c.Param(p)) {
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("globals.messages.invalidUUID")))
}
}
return next(c)
}
}
// subscriberExists middleware checks if a subscriber exists given the UUID
// param in a request.
func subscriberExists(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
var (
app = c.Get("app").(*App)
subUUID = c.Param("subUUID")
)
if _, err := app.core.GetSubscriber(0, subUUID, ""); err != nil {
if er, ok := err.(*echo.HTTPError); ok && er.Code == http.StatusBadRequest {
return c.Render(http.StatusNotFound, tplMessage,
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", er.Message.(string)))
}
app.log.Printf("error checking subscriber existence: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
}
return next(c)
}
}
// noIndex adds the HTTP header requesting robots to not crawl the page.
func noIndex(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set("X-Robots-Tag", "noindex")
return next(c)
}
}
// getPagination takes form values and extracts pagination values from it.
func getPagination(q url.Values, perPage int) pagination {
var (
page, _ = strconv.Atoi(q.Get("page"))
pp = q.Get("per_page")
)
if pp == "all" {
// No limit.
perPage = 0
} else {
ppi, _ := strconv.Atoi(pp)
if ppi > 0 {
perPage = ppi
}
}
if page < 1 {
page = 0
} else {
page--
}
return pagination{
Page: page + 1,
PerPage: perPage,
Offset: page * perPage,
Limit: perPage,
}
}