2018-10-25 21:51:47 +08:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2020-08-08 15:41:49 +08:00
|
|
|
"crypto/subtle"
|
2019-01-03 19:18:47 +08:00
|
|
|
"net/http"
|
2021-09-22 22:44:31 +08:00
|
|
|
"path"
|
2019-07-21 22:41:11 +08:00
|
|
|
"regexp"
|
2018-10-25 21:51:47 +08:00
|
|
|
|
2022-11-09 02:05:31 +08:00
|
|
|
"github.com/knadh/paginator"
|
2021-12-09 23:21:07 +08:00
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
"github.com/labstack/echo/v4/middleware"
|
2018-10-25 21:51:47 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
const (
|
|
|
|
// stdInputMaxLen is the maximum allowed length for a standard input field.
|
2023-08-04 02:22:20 +08:00
|
|
|
stdInputMaxLen = 2000
|
2020-10-24 22:30:29 +08:00
|
|
|
|
|
|
|
sortAsc = "asc"
|
|
|
|
sortDesc = "desc"
|
2018-10-25 21:51:47 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
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"`
|
|
|
|
}
|
|
|
|
|
2020-12-19 18:55:52 +08:00
|
|
|
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}$")
|
2021-08-28 19:01:35 +08:00
|
|
|
reLangCode = regexp.MustCompile("[^a-zA-Z_0-9\\-]")
|
2022-11-09 02:05:31 +08:00
|
|
|
|
|
|
|
paginate = paginator.New(paginator.Opt{
|
|
|
|
DefaultPerPage: 20,
|
|
|
|
MaxPerPage: 50,
|
|
|
|
NumPageNums: 10,
|
|
|
|
PageParam: "page",
|
|
|
|
PerPageParam: "per_page",
|
|
|
|
})
|
2020-12-19 18:55:52 +08:00
|
|
|
)
|
2019-07-21 22:41:11 +08:00
|
|
|
|
2019-01-03 19:18:47 +08:00
|
|
|
// registerHandlers registers HTTP handlers.
|
2021-09-22 22:44:31 +08:00
|
|
|
func initHTTPHandlers(e *echo.Echo, app *App) {
|
2020-08-08 15:41:49 +08:00
|
|
|
// Group of private handlers with BasicAuth.
|
2021-02-20 16:19:14 +08:00
|
|
|
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))
|
|
|
|
}
|
|
|
|
|
2022-10-19 00:14:57 +08:00
|
|
|
e.HTTPErrorHandler = func(err error, c echo.Context) {
|
|
|
|
// Generic, non-echo error. Log it.
|
|
|
|
if _, ok := err.(*echo.HTTPError); !ok {
|
|
|
|
app.log.Println(err.Error())
|
|
|
|
}
|
|
|
|
e.DefaultHTTPErrorHandler(err, c)
|
|
|
|
}
|
|
|
|
|
2021-09-22 22:44:31 +08:00
|
|
|
// Admin JS app views.
|
|
|
|
// /admin/static/* file server is registered in initHTTPServer().
|
2021-09-27 02:12:57 +08:00
|
|
|
e.GET("/", func(c echo.Context) error {
|
|
|
|
return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"})
|
2021-09-22 22:44:31 +08:00
|
|
|
})
|
|
|
|
g.GET(path.Join(adminRoot, ""), handleAdminPage)
|
2021-12-18 18:08:42 +08:00
|
|
|
g.GET(path.Join(adminRoot, "/custom.css"), serveCustomApperance("admin.custom_css"))
|
|
|
|
g.GET(path.Join(adminRoot, "/custom.js"), serveCustomApperance("admin.custom_js"))
|
2021-09-22 22:44:31 +08:00
|
|
|
g.GET(path.Join(adminRoot, "/*"), handleAdminPage)
|
|
|
|
|
|
|
|
// API endpoints.
|
2020-08-08 15:41:49 +08:00
|
|
|
g.GET("/api/health", handleHealthCheck)
|
2021-02-13 15:04:36 +08:00
|
|
|
g.GET("/api/config", handleGetServerConfig)
|
|
|
|
g.GET("/api/lang/:lang", handleGetI18nLang)
|
2020-08-08 15:41:49 +08:00
|
|
|
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
|
|
|
|
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
|
|
|
|
|
|
|
|
g.GET("/api/settings", handleGetSettings)
|
|
|
|
g.PUT("/api/settings", handleUpdateSettings)
|
2022-07-11 21:24:38 +08:00
|
|
|
g.POST("/api/settings/smtp/test", handleTestSMTPSettings)
|
2020-08-08 15:41:49 +08:00
|
|
|
g.POST("/api/admin/reload", handleReloadApp)
|
2020-10-11 02:24:03 +08:00
|
|
|
g.GET("/api/logs", handleGetLogs)
|
2023-06-24 15:37:13 +08:00
|
|
|
g.GET("/api/about", handleGetAboutInfo)
|
2020-08-08 15:41:49 +08:00
|
|
|
|
|
|
|
g.GET("/api/subscribers/:id", handleGetSubscriber)
|
|
|
|
g.GET("/api/subscribers/:id/export", handleExportSubscriberData)
|
2021-05-25 01:11:48 +08:00
|
|
|
g.GET("/api/subscribers/:id/bounces", handleGetSubscriberBounces)
|
|
|
|
g.DELETE("/api/subscribers/:id/bounces", handleDeleteSubscriberBounces)
|
2020-08-08 15:41:49 +08:00
|
|
|
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)
|
2019-01-03 19:18:47 +08:00
|
|
|
|
2021-05-25 01:11:48 +08:00
|
|
|
g.GET("/api/bounces", handleGetBounces)
|
2022-04-03 23:24:40 +08:00
|
|
|
g.GET("/api/bounces/:id", handleGetBounces)
|
2021-05-25 01:11:48 +08:00
|
|
|
g.DELETE("/api/bounces", handleDeleteBounces)
|
|
|
|
g.DELETE("/api/bounces/:id", handleDeleteBounces)
|
|
|
|
|
2019-01-03 19:18:47 +08:00
|
|
|
// Subscriber operations based on arbitrary SQL queries.
|
|
|
|
// These aren't very REST-like.
|
2020-08-08 15:41:49 +08:00
|
|
|
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)
|
2021-01-23 20:53:29 +08:00
|
|
|
g.GET("/api/subscribers/export",
|
|
|
|
middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers))
|
2020-08-08 15:41:49 +08:00
|
|
|
|
|
|
|
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)
|
2022-04-03 23:24:40 +08:00
|
|
|
g.GET("/api/campaigns/:id", handleGetCampaign)
|
2021-09-11 15:27:55 +08:00
|
|
|
g.GET("/api/campaigns/analytics/:type", handleGetCampaignViewAnalytics)
|
2020-08-08 15:41:49 +08:00
|
|
|
g.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
|
|
|
g.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
2021-05-09 18:06:31 +08:00
|
|
|
g.POST("/api/campaigns/:id/content", handleCampaignContent)
|
2021-01-30 17:29:21 +08:00
|
|
|
g.POST("/api/campaigns/:id/text", handlePreviewCampaign)
|
2020-08-08 15:41:49 +08:00
|
|
|
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)
|
2022-11-03 13:37:26 +08:00
|
|
|
g.PUT("/api/campaigns/:id/archive", handleUpdateCampaignArchive)
|
2020-08-08 15:41:49 +08:00
|
|
|
g.DELETE("/api/campaigns/:id", handleDeleteCampaign)
|
|
|
|
|
|
|
|
g.GET("/api/media", handleGetMedia)
|
2022-04-03 23:24:40 +08:00
|
|
|
g.GET("/api/media/:id", handleGetMedia)
|
2020-08-08 15:41:49 +08:00
|
|
|
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)
|
|
|
|
|
2022-09-03 15:48:02 +08:00
|
|
|
g.DELETE("/api/maintenance/subscribers/:type", handleGCSubscribers)
|
|
|
|
g.DELETE("/api/maintenance/analytics/:type", handleGCCampaignAnalytics)
|
|
|
|
g.DELETE("/api/maintenance/subscriptions/unconfirmed", handleGCSubscriptions)
|
|
|
|
|
2022-07-02 18:00:17 +08:00
|
|
|
g.POST("/api/tx", handleSendTxMessage)
|
|
|
|
|
2023-05-27 00:37:58 +08:00
|
|
|
g.GET("/api/events", handleEventStream)
|
|
|
|
|
2021-05-25 01:11:48 +08:00
|
|
|
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)
|
|
|
|
}
|
|
|
|
|
2022-08-28 17:42:20 +08:00
|
|
|
// Public API endpoints.
|
|
|
|
e.GET("/api/public/lists", handleGetPublicLists)
|
|
|
|
e.POST("/api/public/subscription", handlePublicSubscription)
|
2022-11-11 00:54:15 +08:00
|
|
|
|
|
|
|
if app.constants.EnablePublicArchive {
|
|
|
|
e.GET("/api/public/archive", handleGetCampaignArchives)
|
|
|
|
}
|
2022-08-28 17:42:20 +08:00
|
|
|
|
2021-12-18 18:08:42 +08:00
|
|
|
// /public/static/* file server is registered in initHTTPServer().
|
2020-08-08 15:41:49 +08:00
|
|
|
// Public subscriber facing views.
|
2021-01-31 18:49:39 +08:00
|
|
|
e.GET("/subscription/form", handleSubscriptionFormPage)
|
2020-03-07 22:49:22 +08:00
|
|
|
e.POST("/subscription/form", handleSubscriptionForm)
|
2021-06-05 15:15:10 +08:00
|
|
|
e.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage),
|
|
|
|
"campUUID", "subUUID")))
|
2022-10-19 00:14:57 +08:00
|
|
|
e.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs),
|
2019-07-21 22:41:11 +08:00
|
|
|
"campUUID", "subUUID"))
|
2021-06-05 15:15:10 +08:00
|
|
|
e.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID")))
|
2019-12-01 20:18:36 +08:00
|
|
|
e.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID"))
|
2019-07-22 00:28:25 +08:00
|
|
|
e.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData),
|
2019-07-21 22:41:11 +08:00
|
|
|
"subUUID"))
|
2019-07-22 00:28:25 +08:00
|
|
|
e.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData),
|
2019-07-21 22:41:11 +08:00
|
|
|
"subUUID"))
|
2021-06-05 15:15:10 +08:00
|
|
|
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")))
|
2022-11-11 00:36:36 +08:00
|
|
|
|
2022-11-11 00:54:15 +08:00
|
|
|
if app.constants.EnablePublicArchive {
|
|
|
|
e.GET("/archive", handleCampaignArchivesPage)
|
|
|
|
e.GET("/archive.xml", handleGetCampaignArchivesFeed)
|
|
|
|
e.GET("/archive/:uuid", handleCampaignArchivePage)
|
2023-04-08 12:09:10 +08:00
|
|
|
e.GET("/archive/latest", handleCampaignArchivePageLatest)
|
2022-11-11 00:54:15 +08:00
|
|
|
}
|
2021-12-18 18:08:42 +08:00
|
|
|
|
|
|
|
e.GET("/public/custom.css", serveCustomApperance("public.custom_css"))
|
|
|
|
e.GET("/public/custom.js", serveCustomApperance("public.custom_js"))
|
|
|
|
|
2021-06-02 22:37:13 +08:00
|
|
|
// Public health API endpoint.
|
|
|
|
e.GET("/health", handleHealthCheck)
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
2021-09-22 22:44:31 +08:00
|
|
|
// handleAdminPage is the root handler that renders the Javascript admin frontend.
|
|
|
|
func handleAdminPage(c echo.Context) error {
|
2018-10-25 21:51:47 +08:00
|
|
|
app := c.Get("app").(*App)
|
2019-01-03 19:18:47 +08:00
|
|
|
|
2021-09-22 22:44:31 +08:00
|
|
|
b, err := app.fs.Read(path.Join(adminRoot, "/index.html"))
|
2019-01-03 19:18:47 +08:00
|
|
|
if err != nil {
|
2019-03-09 15:44:53 +08:00
|
|
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
2019-01-03 19:18:47 +08:00
|
|
|
}
|
|
|
|
|
2021-09-22 22:44:31 +08:00
|
|
|
return c.HTMLBlob(http.StatusOK, b)
|
2018-10-25 21:51:47 +08:00
|
|
|
}
|
|
|
|
|
2020-07-08 19:00:14 +08:00
|
|
|
// handleHealthCheck is a healthcheck endpoint that returns a 200 response.
|
|
|
|
func handleHealthCheck(c echo.Context) error {
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
|
|
}
|
|
|
|
|
2022-02-28 21:19:50 +08:00
|
|
|
// serveCustomApperance serves the given custom CSS/JS appearance blob
|
2021-12-18 18:08:42 +08:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-08-08 15:41:49 +08:00
|
|
|
// 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
|
|
|
|
}
|
|
|
|
|
2019-07-22 00:28:25 +08:00
|
|
|
// validateUUID middleware validates the UUID string format for a given set of params.
|
2019-07-21 22:41:11 +08:00
|
|
|
func validateUUID(next echo.HandlerFunc, params ...string) echo.HandlerFunc {
|
|
|
|
return func(c echo.Context) error {
|
2020-12-19 18:55:52 +08:00
|
|
|
app := c.Get("app").(*App)
|
|
|
|
|
2019-07-21 22:41:11 +08:00
|
|
|
for _, p := range params {
|
|
|
|
if !reUUID.MatchString(c.Param(p)) {
|
2020-03-07 22:54:42 +08:00
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
2020-12-19 18:55:52 +08:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
|
|
|
|
app.i18n.T("globals.messages.invalidUUID")))
|
2019-07-21 22:41:11 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return next(c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-07-22 00:28:25 +08:00
|
|
|
// 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")
|
|
|
|
)
|
|
|
|
|
2022-04-03 23:24:40 +08:00
|
|
|
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)))
|
|
|
|
}
|
|
|
|
|
2020-03-08 02:33:22 +08:00
|
|
|
app.log.Printf("error checking subscriber existence: %v", err)
|
2020-03-07 22:54:42 +08:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 23:24:40 +08:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
2019-07-22 00:28:25 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
return next(c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-06-05 15:15:10 +08:00
|
|
|
// 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)
|
|
|
|
}
|
|
|
|
}
|