listmonk/cmd/public.go

500 lines
15 KiB
Go
Raw Normal View History

2018-10-25 15:51:47 +02:00
package main
import (
"bytes"
2020-03-07 18:30:55 +01:00
"database/sql"
"fmt"
2018-10-25 15:51:47 +02:00
"html/template"
"image"
"image/png"
2018-10-25 15:51:47 +02:00
"io"
"net/http"
"strconv"
2020-03-07 15:49:22 +01:00
"strings"
2018-10-25 15:51:47 +02:00
2020-12-19 11:55:52 +01:00
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/listmonk/internal/messenger"
"github.com/knadh/listmonk/internal/subimporter"
"github.com/knadh/listmonk/models"
2018-10-25 15:51:47 +02:00
"github.com/labstack/echo"
"github.com/lib/pq"
2018-10-25 15:51:47 +02:00
)
2020-03-07 15:54:42 +01:00
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
2018-10-25 15:51:47 +02:00
}
type publicTpl struct {
Title string
Description string
}
type unsubTpl struct {
publicTpl
SubUUID string
2020-08-01 13:15:29 +02:00
AllowBlocklist bool
AllowExport bool
AllowWipe bool
2018-10-25 15:51:47 +02:00
}
type optinTpl struct {
publicTpl
SubUUID string
ListUUIDs []string `query:"l" form:"l"`
Lists []models.List `query:"-" form:"-"`
}
type msgTpl struct {
2018-10-25 15:51:47 +02:00
publicTpl
MessageTitle string
Message string
2018-10-25 15:51:47 +02:00
}
type subFormTpl struct {
publicTpl
Lists []models.List
}
2020-03-07 15:49:22 +01:00
type subForm struct {
subimporter.SubReq
SubListUUIDs []string `form:"l"`
}
var (
pixelPNG = drawTransparentImage(3, 14)
)
2018-10-25 15:51:47 +02:00
// 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,
2020-12-19 11:55:52 +01:00
L: c.Get("app").(*App).i18n,
})
2018-10-25 15:51:47 +02:00
}
// handleViewCampaignMessage renders the HTML view of a campaign message.
2020-05-19 19:59:58 +02:00
// 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,
2020-12-19 11:55:52 +01:00
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,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
// Get the subscriber.
2021-02-15 13:57:14 +01:00
sub, err := getSubscriber(0, subUUID, "", app)
if err != nil {
if err == sql.ErrNoRows {
return c.Render(http.StatusNotFound, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
app.i18n.T("public.errorFetchingEmail")))
}
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
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,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
// Render the message body.
m := app.manager.NewCampaignMessage(&camp, sub)
if err := m.Render(); err != nil {
app.log.Printf("error rendering message: %v", err)
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingCampaign")))
}
return c.HTML(http.StatusOK, string(m.Body()))
}
// handleSubscriptionPage renders the subscription management page and
2020-05-19 19:59:58 +02:00
// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
// campaigns link to.
func handleSubscriptionPage(c echo.Context) error {
2018-10-25 15:51:47 +02:00
var (
app = c.Get("app").(*App)
campUUID = c.Param("campUUID")
subUUID = c.Param("subUUID")
unsub = c.Request().Method == http.MethodPost
2020-08-01 13:15:29 +02:00
blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
out = unsubTpl{}
2018-10-25 15:51:47 +02:00
)
out.SubUUID = subUUID
2020-12-19 11:55:52 +01:00
out.Title = app.i18n.T("public.unsubscribeTitle")
2020-08-01 13:15:29 +02:00
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
out.AllowExport = app.constants.Privacy.AllowExport
out.AllowWipe = app.constants.Privacy.AllowWipe
2018-10-25 15:51:47 +02:00
// Unsubscribe.
if unsub {
2020-08-01 13:15:29 +02:00
// Is blocklisting allowed?
if !app.constants.Privacy.AllowBlocklist {
blocklist = false
}
2020-08-01 13:15:29 +02:00
if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
app.log.Printf("error unsubscribing: %v", err)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-03-07 18:30:55 +01:00
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusOK, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
app.i18n.T("public.unsubbedInfo")))
2018-10-25 15:51:47 +02:00
}
return c.Render(http.StatusOK, "subscription", out)
2018-10-25 15:51:47 +02:00
}
2020-05-19 19:59:58 +02:00
// 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
2020-12-19 11:55:52 +01:00
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) {
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusBadRequest, tplMessage,
2020-12-19 11:55:52 +01:00
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))
2020-12-19 11:55:52 +01:00
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorFetchingLists")))
}
// There are no lists to confirm.
if len(out.Lists) == 0 {
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
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)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-12-19 11:55:52 +01:00
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusOK, tplMessage,
2021-01-31 09:08:35 +01:00
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); 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)
}
2020-05-19 19:59:58 +02:00
// handleSubscriptionForm handles subscription requests coming from public
// HTML subscription forms.
2020-03-07 15:49:22 +01:00
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 len(req.SubListUUIDs) == 0 {
2020-12-19 11:55:52 +01:00
return c.Render(http.StatusBadRequest, tplMessage,
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.T("public.noListsSelected")))
2020-03-07 15:49:22 +01:00
}
// If there's no name, use the name bit from the e-mail.
req.Email = strings.ToLower(req.Email)
if req.Name == "" {
req.Name = strings.Split(req.Email, "@")[0]
}
// Validate fields.
if err := subimporter.ValidateFields(req.SubReq); err != nil {
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
2020-03-07 15:49:22 +01:00
}
// 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,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
2020-03-07 15:49:22 +01:00
}
msg := "public.subConfirmed"
if hasOptin {
msg = "public.subOptinPending"
}
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
2020-03-07 15:49:22 +01:00
}
2020-05-19 19:59:58 +02:00
// 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,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.invalidLink")))
2020-03-07 18:30:55 +01:00
}
app.log.Printf("error fetching redirect link: %s", err)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
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
2020-05-19 19:59:58 +02:00
// 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)
}
2020-05-19 19:59:58 +02:00
// 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 {
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusBadRequest, tplMessage,
2020-12-19 11:55:52 +01:00
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)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-05-19 19:59:58 +02:00
// Prepare the attachment e-mail.
var msg bytes.Buffer
if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
2020-12-19 11:55:52 +01:00
app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-05-19 19:59:58 +02:00
// 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{
2020-05-16 19:11:30 +02:00
{
Name: fname,
Content: b,
Header: messenger.MakeAttachmentHeader(fname, "base64"),
},
},
}); err != nil {
app.log.Printf("error e-mailing subscriber profile: %s", err)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-12-19 11:55:52 +01:00
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusOK, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
app.i18n.T("public.dataSent")))
}
2020-05-19 19:59:58 +02:00
// 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 {
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusBadRequest, tplMessage,
2020-12-19 11:55:52 +01:00
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)
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusInternalServerError, tplMessage,
2020-12-19 11:55:52 +01:00
makeMsgTpl(app.i18n.T("public.errorTitle"), "",
app.i18n.Ts("public.errorProcessingRequest")))
}
2020-03-07 15:54:42 +01:00
return c.Render(http.StatusOK, tplMessage,
2020-12-19 11:55:52 +01:00
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()
}