package main import ( "bytes" "net/http" "net/url" "path" "regexp" "github.com/knadh/listmonk/internal/auth" "github.com/knadh/paginator" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) const ( // stdInputMaxLen is the maximum allowed length for a standard input field. stdInputMaxLen = 2000 sortAsc = "asc" sortDesc = "desc" basicAuthd = "basicauthd" // URIs. uriAdmin = "/admin" ) 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\\-]") paginate = paginator.New(paginator.Opt{ DefaultPerPage: 20, MaxPerPage: 50, NumPageNums: 10, PageParam: "page", PerPageParam: "per_page", }) ) // registerHandlers registers HTTP handlers. func initHTTPHandlers(e *echo.Echo, app *App) { // Default error handler. 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) } var ( // Authenticated /api/* handlers. api = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { u := c.Get(auth.UserKey) // On no-auth, respond with a JSON error. if err, ok := u.(*echo.HTTPError); ok { return err } return next(c) } }) // Authenticated non /api handlers. a = e.Group("", app.auth.Middleware, func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { u := c.Get(auth.UserKey) // On no-auth, redirect to login page if _, ok := u.(*echo.HTTPError); ok { u, _ := url.Parse(app.constants.LoginURL) q := url.Values{} q.Set("next", c.Request().RequestURI) u.RawQuery = q.Encode() return c.Redirect(http.StatusTemporaryRedirect, u.String()) } return next(c) } }) // Public unauthenticated endpoints. p = e.Group("") ) // Authenticated endpoints. a.GET(path.Join(uriAdmin, ""), handleAdminPage) a.GET(path.Join(uriAdmin, "/custom.css"), serveCustomAppearance("admin.custom_css")) a.GET(path.Join(uriAdmin, "/custom.js"), serveCustomAppearance("admin.custom_js")) a.GET(path.Join(uriAdmin, "/*"), handleAdminPage) pm := app.auth.Perm // API endpoints. api.GET("/api/health", handleHealthCheck) api.GET("/api/config", handleGetServerConfig) api.GET("/api/lang/:lang", handleGetI18nLang) api.GET("/api/dashboard/charts", handleGetDashboardCharts) api.GET("/api/dashboard/counts", handleGetDashboardCounts) api.GET("/api/settings", pm(handleGetSettings, "settings:get")) api.PUT("/api/settings", pm(handleUpdateSettings, "settings:manage")) api.POST("/api/settings/smtp/test", pm(handleTestSMTPSettings, "settings:manage")) api.POST("/api/admin/reload", pm(handleReloadApp, "settings:manage")) api.GET("/api/logs", pm(handleGetLogs, "settings:get")) api.GET("/api/events", pm(handleEventStream, "settings:get")) api.GET("/api/about", handleGetAboutInfo) api.GET("/api/subscribers", pm(handleQuerySubscribers, "subscribers:get_all", "subscribers:get")) api.GET("/api/subscribers/:id", pm(handleGetSubscriber, "subscribers:get_all", "subscribers:get")) api.GET("/api/subscribers/:id/export", pm(handleExportSubscriberData, "subscribers:get_all", "subscribers:get")) api.GET("/api/subscribers/:id/bounces", pm(handleGetSubscriberBounces, "bounces:get")) api.DELETE("/api/subscribers/:id/bounces", pm(handleDeleteSubscriberBounces, "bounces:manage")) api.POST("/api/subscribers", pm(handleCreateSubscriber, "subscribers:manage")) api.PUT("/api/subscribers/:id", pm(handleUpdateSubscriber, "subscribers:manage")) api.POST("/api/subscribers/:id/optin", pm(handleSubscriberSendOptin, "subscribers:manage")) api.PUT("/api/subscribers/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) api.PUT("/api/subscribers/:id/blocklist", pm(handleBlocklistSubscribers, "subscribers:manage")) api.PUT("/api/subscribers/lists/:id", pm(handleManageSubscriberLists, "subscribers:manage")) api.PUT("/api/subscribers/lists", pm(handleManageSubscriberLists, "subscribers:manage")) api.DELETE("/api/subscribers/:id", pm(handleDeleteSubscribers, "subscribers:manage")) api.DELETE("/api/subscribers", pm(handleDeleteSubscribers, "subscribers:manage")) api.GET("/api/bounces", pm(handleGetBounces, "bounces:get")) api.GET("/api/bounces/:id", pm(handleGetBounces, "bounces:get")) api.DELETE("/api/bounces", pm(handleDeleteBounces, "bounces:manage")) api.DELETE("/api/bounces/:id", pm(handleDeleteBounces, "bounces:manage")) // Subscriber operations based on arbitrary SQL queries. // These aren't very REST-like. api.POST("/api/subscribers/query/delete", pm(handleDeleteSubscribersByQuery, "subscribers:manage")) api.PUT("/api/subscribers/query/blocklist", pm(handleBlocklistSubscribersByQuery, "subscribers:manage")) api.PUT("/api/subscribers/query/lists", pm(handleManageSubscriberListsByQuery, "subscribers:manage")) api.GET("/api/subscribers/export", pm(middleware.GzipWithConfig(middleware.GzipConfig{Level: 9})(handleExportSubscribers), "subscribers:get_all", "subscribers:get")) api.GET("/api/import/subscribers", pm(handleGetImportSubscribers, "subscribers:import")) api.GET("/api/import/subscribers/logs", pm(handleGetImportSubscriberStats, "subscribers:import")) api.POST("/api/import/subscribers", pm(handleImportSubscribers, "subscribers:import")) api.DELETE("/api/import/subscribers", pm(handleStopImportSubscribers, "subscribers:import")) // Individual list permissions are applied directly within handleGetLists. api.GET("/api/lists", handleGetLists) api.GET("/api/lists/:id", listPerm(handleGetLists)) api.POST("/api/lists", pm(handleCreateList, "lists:manage_all")) api.PUT("/api/lists/:id", listPerm(handleUpdateList)) api.DELETE("/api/lists/:id", listPerm(handleDeleteLists)) api.GET("/api/campaigns", pm(handleGetCampaigns, "campaigns:get")) api.GET("/api/campaigns/running/stats", pm(handleGetRunningCampaignStats, "campaigns:get")) api.GET("/api/campaigns/:id", pm(handleGetCampaign, "campaigns:get")) api.GET("/api/campaigns/analytics/:type", pm(handleGetCampaignViewAnalytics, "campaigns:get_analytics")) api.GET("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) api.POST("/api/campaigns/:id/preview", pm(handlePreviewCampaign, "campaigns:get")) api.POST("/api/campaigns/:id/content", pm(handleCampaignContent, "campaigns:manage")) api.POST("/api/campaigns/:id/text", pm(handlePreviewCampaign, "campaigns:manage")) api.POST("/api/campaigns/:id/test", pm(handleTestCampaign, "campaigns:manage")) api.POST("/api/campaigns", pm(handleCreateCampaign, "campaigns:manage")) api.PUT("/api/campaigns/:id", pm(handleUpdateCampaign, "campaigns:manage")) api.PUT("/api/campaigns/:id/status", pm(handleUpdateCampaignStatus, "campaigns:manage")) api.PUT("/api/campaigns/:id/archive", pm(handleUpdateCampaignArchive, "campaigns:manage")) api.DELETE("/api/campaigns/:id", pm(handleDeleteCampaign, "campaigns:manage")) api.GET("/api/media", pm(handleGetMedia, "media:get")) api.GET("/api/media/:id", pm(handleGetMedia, "media:get")) api.POST("/api/media", pm(handleUploadMedia, "media:manage")) api.DELETE("/api/media/:id", pm(handleDeleteMedia, "media:manage")) api.GET("/api/templates", pm(handleGetTemplates, "templates:get")) api.GET("/api/templates/:id", pm(handleGetTemplates, "templates:get")) api.GET("/api/templates/:id/preview", pm(handlePreviewTemplate, "templates:get")) api.POST("/api/templates/preview", pm(handlePreviewTemplate, "templates:get")) api.POST("/api/templates", pm(handleCreateTemplate, "templates:manage")) api.PUT("/api/templates/:id", pm(handleUpdateTemplate, "templates:manage")) api.PUT("/api/templates/:id/default", pm(handleTemplateSetDefault, "templates:manage")) api.DELETE("/api/templates/:id", pm(handleDeleteTemplate, "templates:manage")) api.DELETE("/api/maintenance/subscribers/:type", pm(handleGCSubscribers, "settings:maintain")) api.DELETE("/api/maintenance/analytics/:type", pm(handleGCCampaignAnalytics, "settings:maintain")) api.DELETE("/api/maintenance/subscriptions/unconfirmed", pm(handleGCSubscriptions, "settings:maintain")) api.POST("/api/tx", pm(handleSendTxMessage, "tx:send")) api.GET("/api/profile", handleGetUserProfile) api.PUT("/api/profile", handleUpdateUserProfile) api.GET("/api/users", pm(handleGetUsers, "users:get")) api.GET("/api/users/:id", pm(handleGetUsers, "users:get")) api.POST("/api/users", pm(handleCreateUser, "users:manage")) api.PUT("/api/users/:id", pm(handleUpdateUser, "users:manage")) api.DELETE("/api/users", pm(handleDeleteUsers, "users:manage")) api.DELETE("/api/users/:id", pm(handleDeleteUsers, "users:manage")) api.POST("/api/logout", handleLogout) api.GET("/api/roles", pm(handleGetRoles, "roles:get")) api.POST("/api/roles", pm(handleCreateRole, "roles:manage")) api.PUT("/api/roles/:id", pm(handleUpdateRole, "roles:manage")) api.DELETE("/api/roles/:id", pm(handleDeleteRole, "roles:manage")) if app.constants.BounceWebhooksEnabled { // Private authenticated bounce endpoint. api.POST("/webhooks/bounce", pm(handleBounceWebhook, "webhooks:post_bounce")) // Public bounce endpoints for webservices like SES. p.POST("/webhooks/service/:service", handleBounceWebhook) } // ================================================================= // Public API endpoints. // Landing page. p.GET("/", func(c echo.Context) error { return c.Render(http.StatusOK, "home", publicTpl{Title: "listmonk"}) }) // Public admin endpoints (login page, OIDC endpoints). p.GET(path.Join(uriAdmin, "/login"), handleLoginPage) p.POST(path.Join(uriAdmin, "/login"), handleLoginPage) if app.constants.Security.OIDC.Enabled { p.POST("/auth/oidc", handleOIDCLogin) p.GET("/auth/oidc", handleOIDCFinish) } // Public APIs. p.GET("/api/public/lists", handleGetPublicLists) p.POST("/api/public/subscription", handlePublicSubscription) if app.constants.EnablePublicArchive { p.GET("/api/public/archive", handleGetCampaignArchives) } // /public/static/* file server is registered in initHTTPServer(). // Public subscriber facing views. p.GET("/subscription/form", handleSubscriptionFormPage) p.POST("/subscription/form", handleSubscriptionForm) p.GET("/subscription/:campUUID/:subUUID", noIndex(validateUUID(subscriberExists(handleSubscriptionPage), "campUUID", "subUUID"))) p.POST("/subscription/:campUUID/:subUUID", validateUUID(subscriberExists(handleSubscriptionPrefs), "campUUID", "subUUID")) p.GET("/subscription/optin/:subUUID", noIndex(validateUUID(subscriberExists(handleOptinPage), "subUUID"))) p.POST("/subscription/optin/:subUUID", validateUUID(subscriberExists(handleOptinPage), "subUUID")) p.POST("/subscription/export/:subUUID", validateUUID(subscriberExists(handleSelfExportSubscriberData), "subUUID")) p.POST("/subscription/wipe/:subUUID", validateUUID(subscriberExists(handleWipeSubscriberData), "subUUID")) p.GET("/link/:linkUUID/:campUUID/:subUUID", noIndex(validateUUID(handleLinkRedirect, "linkUUID", "campUUID", "subUUID"))) p.GET("/campaign/:campUUID/:subUUID", noIndex(validateUUID(handleViewCampaignMessage, "campUUID", "subUUID"))) p.GET("/campaign/:campUUID/:subUUID/px.png", noIndex(validateUUID(handleRegisterCampaignView, "campUUID", "subUUID"))) if app.constants.EnablePublicArchive { p.GET("/archive", handleCampaignArchivesPage) p.GET("/archive.xml", handleGetCampaignArchivesFeed) p.GET("/archive/:id", handleCampaignArchivePage) p.GET("/archive/latest", handleCampaignArchivePageLatest) } p.GET("/public/custom.css", serveCustomAppearance("public.custom_css")) p.GET("/public/custom.js", serveCustomAppearance("public.custom_js")) // Public health API endpoint. p.GET("/health", handleHealthCheck) // 404 pages. p.RouteNotFound("/*", func(c echo.Context) error { return c.Render(http.StatusNotFound, tplMessage, makeMsgTpl("404 - "+app.i18n.T("public.notFoundTitle"), "", "")) }) p.RouteNotFound("/api/*", func(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, "404 unknown endpoint") }) p.RouteNotFound("/admin/*", func(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, "404 page not found") }) } // 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(uriAdmin, "/index.html")) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } b = bytes.ReplaceAll(b, []byte("asset_version"), []byte(app.constants.AssetVersion)) 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}) } // serveCustomAppearance serves the given custom CSS/JS appearance blob // meant for customizing public and admin pages from the admin settings UI. func serveCustomAppearance(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) } } // 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) } }