123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301 |
- package main
- import (
- "crypto/subtle"
- "net/http"
- "net/url"
- "path"
- "regexp"
- "strconv"
- "github.com/labstack/echo"
- "github.com/labstack/echo/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().
- g.GET("/", func(c echo.Context) error {
- return c.Redirect(http.StatusPermanentRedirect, path.Join(adminRoot, ""))
- })
- g.GET(path.Join(adminRoot, ""), handleAdminPage)
- 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.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", handleGetCampaigns)
- 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.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 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")))
- // 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})
- }
- // 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")
- )
- var exists bool
- if err := app.queries.SubscriberExists.Get(&exists, 0, subUUID); err != nil {
- 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")))
- }
- if !exists {
- return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
- app.i18n.T("public.subNotFound")))
- }
- 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,
- }
- }
- // copyEchoCtx returns a copy of the the current echo.Context in a request
- // with the given params set for the active handler to proxy the request
- // to another handler without mutating its context.
- func copyEchoCtx(c echo.Context, params map[string]string) echo.Context {
- var (
- keys = make([]string, 0, len(params))
- vals = make([]string, 0, len(params))
- )
- for k, v := range params {
- keys = append(keys, k)
- vals = append(vals, v)
- }
- b := c.Echo().NewContext(c.Request(), c.Response())
- b.Set("app", c.Get("app").(*App))
- b.SetParamNames(keys...)
- b.SetParamValues(vals...)
- return b
- }
|