123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510 |
- package main
- import (
- "bytes"
- "database/sql"
- "fmt"
- "html/template"
- "image"
- "image/png"
- "io"
- "net/http"
- "strconv"
- "strings"
- "github.com/knadh/listmonk/internal/i18n"
- "github.com/knadh/listmonk/internal/messenger"
- "github.com/knadh/listmonk/internal/subimporter"
- "github.com/knadh/listmonk/models"
- "github.com/labstack/echo"
- "github.com/lib/pq"
- )
- const (
- tplMessage = "message"
- )
- // tplRenderer wraps a template.tplRenderer for echo.
- type tplRenderer struct {
- templates *template.Template
- RootURL string
- LogoURL string
- FaviconURL string
- }
- // tplData is the data container that is injected
- // into public templates for accessing data.
- type tplData struct {
- RootURL string
- LogoURL string
- FaviconURL string
- Data interface{}
- L *i18n.I18n
- }
- type publicTpl struct {
- Title string
- Description string
- }
- type unsubTpl struct {
- publicTpl
- SubUUID string
- AllowBlocklist bool
- AllowExport bool
- AllowWipe bool
- }
- type optinTpl struct {
- publicTpl
- SubUUID string
- ListUUIDs []string `query:"l" form:"l"`
- Lists []models.List `query:"-" form:"-"`
- }
- type msgTpl struct {
- publicTpl
- MessageTitle string
- Message string
- }
- type subFormTpl struct {
- publicTpl
- Lists []models.List
- }
- type subForm struct {
- subimporter.SubReq
- SubListUUIDs []string `form:"l"`
- }
- var (
- pixelPNG = drawTransparentImage(3, 14)
- )
- // Render executes and renders a template for echo.
- func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
- return t.templates.ExecuteTemplate(w, name, tplData{
- RootURL: t.RootURL,
- LogoURL: t.LogoURL,
- FaviconURL: t.FaviconURL,
- Data: data,
- L: c.Get("app").(*App).i18n,
- })
- }
- // handleViewCampaignMessage renders the HTML view of a campaign message.
- // This is the view the {{ MessageURL }} template tag links to in e-mail campaigns.
- func handleViewCampaignMessage(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- campUUID = c.Param("campUUID")
- subUUID = c.Param("subUUID")
- )
- // Get the campaign.
- var camp models.Campaign
- if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
- if err == sql.ErrNoRows {
- return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
- app.i18n.T("public.campaignNotFound")))
- }
- app.log.Printf("error fetching campaign: %v", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingCampaign")))
- }
- // Get the subscriber.
- sub, err := getSubscriber(0, subUUID, "", app)
- if err != nil {
- if err == sql.ErrNoRows {
- return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
- app.i18n.T("public.errorFetchingEmail")))
- }
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingCampaign")))
- }
- // Compile the template.
- if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
- app.log.Printf("error compiling template: %v", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingCampaign")))
- }
- // Render the message body.
- msg, err := app.manager.NewCampaignMessage(&camp, sub)
- if err != nil {
- app.log.Printf("error rendering message: %v", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingCampaign")))
- }
- return c.HTML(http.StatusOK, string(msg.Body()))
- }
- // handleSubscriptionPage renders the subscription management page and
- // handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
- // campaigns link to.
- func handleSubscriptionPage(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- campUUID = c.Param("campUUID")
- subUUID = c.Param("subUUID")
- unsub = c.Request().Method == http.MethodPost
- blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
- out = unsubTpl{}
- )
- out.SubUUID = subUUID
- out.Title = app.i18n.T("public.unsubscribeTitle")
- out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
- out.AllowExport = app.constants.Privacy.AllowExport
- out.AllowWipe = app.constants.Privacy.AllowWipe
- // Unsubscribe.
- if unsub {
- // Is blocklisting allowed?
- if !app.constants.Privacy.AllowBlocklist {
- blocklist = false
- }
- if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
- app.log.Printf("error unsubscribing: %v", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
- app.i18n.T("public.unsubbedInfo")))
- }
- return c.Render(http.StatusOK, "subscription", out)
- }
- // handleOptinPage renders the double opt-in confirmation page that subscribers
- // see when they click on the "Confirm subscription" button in double-optin
- // notifications.
- func handleOptinPage(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- subUUID = c.Param("subUUID")
- confirm, _ = strconv.ParseBool(c.FormValue("confirm"))
- out = optinTpl{}
- )
- out.SubUUID = subUUID
- out.Title = app.i18n.T("public.confirmOptinSubTitle")
- out.SubUUID = subUUID
- // Get and validate fields.
- if err := c.Bind(&out); err != nil {
- return err
- }
- // Validate list UUIDs if there are incoming UUIDs in the request.
- if len(out.ListUUIDs) > 0 {
- for _, l := range out.ListUUIDs {
- if !reUUID.MatchString(l) {
- return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.T("globals.messages.invalidUUID")))
- }
- }
- }
- // Get the list of subscription lists where the subscriber hasn't confirmed.
- if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
- nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
- app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingLists")))
- }
- // There are no lists to confirm.
- if len(out.Lists) == 0 {
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
- app.i18n.Ts("public.noSubInfo")))
- }
- // Confirm.
- if confirm {
- if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
- app.log.Printf("error unsubscribing: %v", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "",
- app.i18n.Ts("public.subConfirmed")))
- }
- return c.Render(http.StatusOK, "optin", out)
- }
- // handleSubscriptionFormPage handles subscription requests coming from public
- // HTML subscription forms.
- func handleSubscriptionFormPage(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- )
- if !app.constants.EnablePublicSubPage {
- return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.invalidFeature")))
- }
- // Get all public lists.
- var lists []models.List
- if err := app.queries.GetLists.Select(&lists, models.ListTypePublic, "name"); err != nil {
- app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err))
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorFetchingLists")))
- }
- if len(lists) == 0 {
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.noListsAvailable")))
- }
- out := subFormTpl{}
- out.Title = app.i18n.T("public.sub")
- out.Lists = lists
- return c.Render(http.StatusOK, "subscription-form", out)
- }
- // handleSubscriptionForm handles subscription requests coming from public
- // HTML subscription forms.
- func handleSubscriptionForm(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- req subForm
- )
- // Get and validate fields.
- if err := c.Bind(&req); err != nil {
- return err
- }
- // If there's a nonce value, a bot could've filled the form.
- if c.FormValue("nonce") != "" {
- return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.T("public.invalidFeature")))
- }
- if len(req.SubListUUIDs) == 0 {
- return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.T("public.noListsSelected")))
- }
- // If there's no name, use the name bit from the e-mail.
- req.Name = strings.TrimSpace(req.Name)
- if req.Name == "" {
- req.Name = strings.Split(req.Email, "@")[0]
- }
- // Validate fields.
- if r, err := app.importer.ValidateFields(req.SubReq); err != nil {
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
- } else {
- req.SubReq = r
- }
- // Insert the subscriber into the DB.
- req.Status = models.SubscriberStatusEnabled
- req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
- _, _, hasOptin, err := insertSubscriber(req.SubReq, app)
- if err != nil {
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
- }
- msg := "public.subConfirmed"
- if hasOptin {
- msg = "public.subOptinPending"
- }
- return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
- }
- // handleLinkRedirect redirects a link UUID to its original underlying link
- // after recording the link click for a particular subscriber in the particular
- // campaign. These links are generated by {{ TrackLink }} tags in campaigns.
- func handleLinkRedirect(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- linkUUID = c.Param("linkUUID")
- campUUID = c.Param("campUUID")
- subUUID = c.Param("subUUID")
- )
- // If individual tracking is disabled, do not record the subscriber ID.
- if !app.constants.Privacy.IndividualTracking {
- subUUID = ""
- }
- var url string
- if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
- if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
- return c.Render(http.StatusNotFound, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.invalidLink")))
- }
- app.log.Printf("error fetching redirect link: %s", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- return c.Redirect(http.StatusTemporaryRedirect, url)
- }
- // handleRegisterCampaignView registers a campaign view which comes in
- // the form of an pixel image request. Regardless of errors, this handler
- // should always render the pixel image bytes. The pixel URL is is generated by
- // the {{ TrackView }} template tag in campaigns.
- func handleRegisterCampaignView(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- campUUID = c.Param("campUUID")
- subUUID = c.Param("subUUID")
- )
- // If individual tracking is disabled, do not record the subscriber ID.
- if !app.constants.Privacy.IndividualTracking {
- subUUID = ""
- }
- // Exclude dummy hits from template previews.
- if campUUID != dummyUUID && subUUID != dummyUUID {
- if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
- app.log.Printf("error registering campaign view: %s", err)
- }
- }
- c.Response().Header().Set("Cache-Control", "no-cache")
- return c.Blob(http.StatusOK, "image/png", pixelPNG)
- }
- // handleSelfExportSubscriberData pulls the subscriber's profile, list subscriptions,
- // campaign views and clicks and produces a JSON report that is then e-mailed
- // to the subscriber. This is a privacy feature and the data that's exported
- // is dependent on the configuration.
- func handleSelfExportSubscriberData(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- subUUID = c.Param("subUUID")
- )
- // Is export allowed?
- if !app.constants.Privacy.AllowExport {
- return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.invalidFeature")))
- }
- // Get the subscriber's data. A single query that gets the profile,
- // list subscriptions, campaign views, and link clicks. Names of
- // private lists are replaced with "Private list".
- data, b, err := exportSubscriberData(0, subUUID, app.constants.Privacy.Exportable, app)
- if err != nil {
- app.log.Printf("error exporting subscriber data: %s", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- // Prepare the attachment e-mail.
- var msg bytes.Buffer
- if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
- app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- // Send the data as a JSON attachment to the subscriber.
- const fname = "data.json"
- if err := app.messengers[emailMsgr].Push(messenger.Message{
- From: app.constants.FromEmail,
- To: []string{data.Email},
- Subject: "Your data",
- Body: msg.Bytes(),
- Attachments: []messenger.Attachment{
- {
- Name: fname,
- Content: b,
- Header: messenger.MakeAttachmentHeader(fname, "base64"),
- },
- },
- }); err != nil {
- app.log.Printf("error e-mailing subscriber profile: %s", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
- app.i18n.T("public.dataSent")))
- }
- // handleWipeSubscriberData allows a subscriber to delete their data. The
- // profile and subscriptions are deleted, while the campaign_views and link
- // clicks remain as orphan data unconnected to any subscriber.
- func handleWipeSubscriberData(c echo.Context) error {
- var (
- app = c.Get("app").(*App)
- subUUID = c.Param("subUUID")
- )
- // Is wiping allowed?
- if !app.constants.Privacy.AllowWipe {
- return c.Render(http.StatusBadRequest, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.invalidFeature")))
- }
- if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
- app.log.Printf("error wiping subscriber data: %s", err)
- return c.Render(http.StatusInternalServerError, tplMessage,
- makeMsgTpl(app.i18n.T("public.errorTitle"), "",
- app.i18n.Ts("public.errorProcessingRequest")))
- }
- return c.Render(http.StatusOK, tplMessage,
- makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
- app.i18n.T("public.dataRemoved")))
- }
- // drawTransparentImage draws a transparent PNG of given dimensions
- // and returns the PNG bytes.
- func drawTransparentImage(h, w int) []byte {
- var (
- img = image.NewRGBA(image.Rect(0, 0, w, h))
- out = &bytes.Buffer{}
- )
- _ = png.Encode(out, img)
- return out.Bytes()
- }
|