2018-10-25 15:51:47 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2018-11-02 08:50:32 +01:00
|
|
|
"bytes"
|
2018-10-25 15:51:47 +02:00
|
|
|
"html/template"
|
2018-11-02 08:50:32 +01:00
|
|
|
"image"
|
|
|
|
"image/png"
|
2018-10-25 15:51:47 +02:00
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"regexp"
|
|
|
|
"strconv"
|
|
|
|
|
2019-07-21 15:48:41 +02:00
|
|
|
"github.com/knadh/listmonk/messenger"
|
2018-10-25 15:51:47 +02:00
|
|
|
"github.com/labstack/echo"
|
2019-07-21 15:48:41 +02:00
|
|
|
"github.com/lib/pq"
|
2018-10-25 15:51:47 +02:00
|
|
|
)
|
|
|
|
|
2019-01-03 12:18:47 +01:00
|
|
|
// 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{}
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
type publicTpl struct {
|
|
|
|
Title string
|
|
|
|
Description string
|
|
|
|
}
|
|
|
|
|
|
|
|
type unsubTpl struct {
|
|
|
|
publicTpl
|
2019-07-21 16:13:43 +02:00
|
|
|
SubUUID string
|
|
|
|
AllowBlacklist bool
|
|
|
|
AllowExport bool
|
|
|
|
AllowWipe bool
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
2019-07-18 09:14:46 +02:00
|
|
|
type msgTpl struct {
|
2018-10-25 15:51:47 +02:00
|
|
|
publicTpl
|
2019-07-18 09:14:46 +02:00
|
|
|
MessageTitle string
|
|
|
|
Message string
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
2018-11-02 08:50:32 +01:00
|
|
|
var (
|
|
|
|
regexValidUUID = regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
|
|
|
|
pixelPNG = drawTransparentImage(3, 14)
|
|
|
|
)
|
2018-10-25 15:51:47 +02:00
|
|
|
|
2018-10-31 13:52:54 +01:00
|
|
|
// Render executes and renders a template for echo.
|
2019-01-03 12:18:47 +01:00
|
|
|
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,
|
|
|
|
})
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
2019-07-21 15:48:41 +02:00
|
|
|
// handleSubscriptionPage renders the subscription management page and
|
|
|
|
// handles unsubscriptions.
|
|
|
|
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")
|
2018-10-31 13:52:54 +01:00
|
|
|
unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
|
2018-10-25 15:51:47 +02:00
|
|
|
blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
|
2019-07-21 15:48:41 +02:00
|
|
|
out = unsubTpl{}
|
2018-10-25 15:51:47 +02:00
|
|
|
)
|
2019-07-21 15:48:41 +02:00
|
|
|
out.SubUUID = subUUID
|
2018-10-25 15:51:47 +02:00
|
|
|
out.Title = "Unsubscribe from mailing list"
|
2019-07-21 16:13:43 +02:00
|
|
|
out.AllowBlacklist = app.Constants.Privacy.AllowBlacklist
|
2019-07-21 15:48:41 +02:00
|
|
|
out.AllowExport = app.Constants.Privacy.AllowExport
|
|
|
|
out.AllowWipe = app.Constants.Privacy.AllowWipe
|
2018-10-25 15:51:47 +02:00
|
|
|
|
|
|
|
if !regexValidUUID.MatchString(campUUID) ||
|
|
|
|
!regexValidUUID.MatchString(subUUID) {
|
2019-07-18 09:14:46 +02:00
|
|
|
return c.Render(http.StatusBadRequest, "message",
|
|
|
|
makeMsgTpl("Invalid request", "",
|
2018-10-31 13:52:54 +01:00
|
|
|
`The unsubscription request contains invalid IDs.
|
2019-01-03 12:18:47 +01:00
|
|
|
Please follow the correct link.`))
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// Unsubscribe.
|
2018-10-31 13:52:54 +01:00
|
|
|
if unsub {
|
2019-07-21 16:13:43 +02:00
|
|
|
// Is blacklisting allowed?
|
|
|
|
if !app.Constants.Privacy.AllowBlacklist {
|
|
|
|
blacklist = false
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := app.Queries.Unsubscribe.Exec(campUUID, subUUID, blacklist); err != nil {
|
2018-10-31 13:52:54 +01:00
|
|
|
app.Logger.Printf("Error unsubscribing : %v", err)
|
2019-01-03 12:18:47 +01:00
|
|
|
return echo.NewHTTPError(http.StatusBadRequest,
|
|
|
|
"There was an internal error while unsubscribing you.")
|
2018-10-31 13:52:54 +01:00
|
|
|
}
|
2019-07-21 16:13:43 +02:00
|
|
|
return c.Render(http.StatusOK, "message",
|
|
|
|
makeMsgTpl("Unsubscribed", "",
|
|
|
|
`You have been successfully unsubscribed.`))
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
|
|
|
|
2019-07-21 15:48:41 +02:00
|
|
|
return c.Render(http.StatusOK, "subscription", out)
|
2018-10-25 15:51:47 +02:00
|
|
|
}
|
2018-10-31 13:52:54 +01:00
|
|
|
|
|
|
|
// handleLinkRedirect handles link UUID to real link redirection.
|
|
|
|
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 !regexValidUUID.MatchString(linkUUID) ||
|
|
|
|
!regexValidUUID.MatchString(campUUID) ||
|
|
|
|
!regexValidUUID.MatchString(subUUID) {
|
2019-07-18 09:14:46 +02:00
|
|
|
return c.Render(http.StatusBadRequest, "message",
|
|
|
|
makeMsgTpl("Invalid link", "", "The link you clicked is invalid."))
|
2018-10-31 13:52:54 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
var url string
|
|
|
|
if err := app.Queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
|
|
|
|
app.Logger.Printf("error fetching redirect link: %s", err)
|
2019-07-18 09:14:46 +02:00
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Error opening link", "",
|
2019-01-03 12:18:47 +01:00
|
|
|
"There was an error opening the link. Please try later."))
|
2018-10-31 13:52:54 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, url)
|
|
|
|
}
|
2018-11-02 08:50:32 +01:00
|
|
|
|
|
|
|
// 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.
|
|
|
|
func handleRegisterCampaignView(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
campUUID = c.Param("campUUID")
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
|
|
|
if regexValidUUID.MatchString(campUUID) &&
|
|
|
|
regexValidUUID.MatchString(subUUID) {
|
|
|
|
if _, err := app.Queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
|
|
|
|
app.Logger.Printf("error registering campaign view: %s", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-11-29 10:57:21 +01:00
|
|
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
2018-11-02 08:50:32 +01:00
|
|
|
return c.Blob(http.StatusOK, "image/png", pixelPNG)
|
|
|
|
}
|
|
|
|
|
2019-07-21 15:48:41 +02:00
|
|
|
// handleSelfExportSubscriberData pulls the subscriber's profile,
|
|
|
|
// list subscriptions, campaign views and clicks and produces
|
|
|
|
// a JSON report. This is a privacy feature and depends on the
|
|
|
|
// configuration in app.Constants.Privacy.
|
|
|
|
func handleSelfExportSubscriberData(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
|
|
|
if !regexValidUUID.MatchString(subUUID) {
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Invalid request", "",
|
|
|
|
"The subscriber ID is invalid."))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Is export allowed?
|
|
|
|
if !app.Constants.Privacy.AllowExport {
|
|
|
|
return c.Render(http.StatusBadRequest, "message",
|
|
|
|
makeMsgTpl("Invalid request", "",
|
|
|
|
"The feature is not available."))
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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.Logger.Printf("error exporting subscriber data: %s", err)
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Error processing request", "",
|
|
|
|
"There was an error processing your request. Please try later."))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Send the data out to the subscriber as an atachment.
|
|
|
|
msg, err := getNotificationTemplate("subscriber-data", nil, app)
|
|
|
|
if err != nil {
|
|
|
|
app.Logger.Printf("error preparing subscriber data e-mail template: %s", err)
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Error preparing data", "",
|
|
|
|
"There was an error preparing your data. Please try later."))
|
|
|
|
}
|
|
|
|
|
|
|
|
const fname = "profile.json"
|
|
|
|
if err := app.Messenger.Push(app.Constants.FromEmail,
|
|
|
|
[]string{data.Email},
|
|
|
|
"Your profile data",
|
|
|
|
msg,
|
|
|
|
[]*messenger.Attachment{
|
|
|
|
&messenger.Attachment{
|
|
|
|
Name: fname,
|
|
|
|
Content: b,
|
|
|
|
Header: messenger.MakeAttachmentHeader(fname, "base64"),
|
|
|
|
},
|
|
|
|
},
|
|
|
|
); err != nil {
|
|
|
|
app.Logger.Printf("error e-mailing subscriber profile: %s", err)
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Error e-mailing data", "",
|
|
|
|
"There was an error e-mailing your data. Please try later."))
|
|
|
|
}
|
|
|
|
return c.Render(http.StatusOK, "message",
|
|
|
|
makeMsgTpl("Data e-mailed", "",
|
|
|
|
`Your data has been e-mailed to you as an attachment.`))
|
|
|
|
}
|
|
|
|
|
|
|
|
// handleWipeSubscriberData allows a subscriber to self-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")
|
|
|
|
)
|
|
|
|
if !regexValidUUID.MatchString(subUUID) {
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Invalid request", "",
|
|
|
|
"The subscriber ID is invalid."))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Is wiping allowed?
|
|
|
|
if !app.Constants.Privacy.AllowExport {
|
|
|
|
return c.Render(http.StatusBadRequest, "message",
|
|
|
|
makeMsgTpl("Invalid request", "",
|
|
|
|
"The feature is not available."))
|
|
|
|
}
|
|
|
|
|
|
|
|
if _, err := app.Queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
|
|
|
|
app.Logger.Printf("error wiping subscriber data: %s", err)
|
|
|
|
return c.Render(http.StatusInternalServerError, "message",
|
|
|
|
makeMsgTpl("Error processing request", "",
|
|
|
|
"There was an error processing your request. Please try later."))
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusOK, "message",
|
|
|
|
makeMsgTpl("Data removed", "",
|
|
|
|
`Your subscriptions and all associated data has been removed.`))
|
|
|
|
}
|
|
|
|
|
2018-11-02 08:50:32 +01:00
|
|
|
// 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()
|
|
|
|
}
|