2018-10-25 13:51:47 +00:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
2018-11-02 07:50:32 +00:00
|
|
|
"bytes"
|
2020-03-07 17:30:55 +00:00
|
|
|
"database/sql"
|
2020-05-16 18:00:43 +00:00
|
|
|
"fmt"
|
2018-10-25 13:51:47 +00:00
|
|
|
"html/template"
|
2018-11-02 07:50:32 +00:00
|
|
|
"image"
|
|
|
|
"image/png"
|
2018-10-25 13:51:47 +00:00
|
|
|
"io"
|
|
|
|
"net/http"
|
|
|
|
"strconv"
|
2020-03-07 14:49:22 +00:00
|
|
|
"strings"
|
2018-10-25 13:51:47 +00:00
|
|
|
|
2020-12-19 10:55:52 +00:00
|
|
|
"github.com/knadh/listmonk/internal/i18n"
|
2023-05-08 17:13:25 +00:00
|
|
|
"github.com/knadh/listmonk/internal/manager"
|
2019-12-01 12:18:36 +00:00
|
|
|
"github.com/knadh/listmonk/models"
|
2021-12-09 15:21:07 +00:00
|
|
|
"github.com/labstack/echo/v4"
|
2019-07-21 13:48:41 +00:00
|
|
|
"github.com/lib/pq"
|
2018-10-25 13:51:47 +00:00
|
|
|
)
|
|
|
|
|
2020-03-07 14:54:42 +00:00
|
|
|
const (
|
|
|
|
tplMessage = "message"
|
|
|
|
)
|
|
|
|
|
2019-01-03 11:18:47 +00:00
|
|
|
// tplRenderer wraps a template.tplRenderer for echo.
|
|
|
|
type tplRenderer struct {
|
2022-11-09 16:54:34 +00:00
|
|
|
templates *template.Template
|
2022-11-10 15:49:53 +00:00
|
|
|
SiteName string
|
2022-11-09 16:54:34 +00:00
|
|
|
RootURL string
|
|
|
|
LogoURL string
|
|
|
|
FaviconURL string
|
|
|
|
EnablePublicSubPage bool
|
2022-11-10 16:54:15 +00:00
|
|
|
EnablePublicArchive bool
|
2019-01-03 11:18:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// tplData is the data container that is injected
|
|
|
|
// into public templates for accessing data.
|
|
|
|
type tplData struct {
|
2022-11-10 15:49:53 +00:00
|
|
|
SiteName string
|
2022-11-09 16:54:34 +00:00
|
|
|
RootURL string
|
|
|
|
LogoURL string
|
|
|
|
FaviconURL string
|
|
|
|
EnablePublicSubPage bool
|
2022-11-10 16:54:15 +00:00
|
|
|
EnablePublicArchive bool
|
2022-11-09 16:54:34 +00:00
|
|
|
Data interface{}
|
|
|
|
L *i18n.I18n
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type publicTpl struct {
|
|
|
|
Title string
|
|
|
|
Description string
|
|
|
|
}
|
|
|
|
|
|
|
|
type unsubTpl struct {
|
|
|
|
publicTpl
|
2022-10-18 16:14:57 +00:00
|
|
|
Subscriber models.Subscriber
|
|
|
|
Subscriptions []models.Subscription
|
|
|
|
SubUUID string
|
|
|
|
AllowBlocklist bool
|
|
|
|
AllowExport bool
|
|
|
|
AllowWipe bool
|
|
|
|
AllowPreferences bool
|
|
|
|
ShowManage bool
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2019-12-01 12:18:36 +00:00
|
|
|
type optinTpl struct {
|
|
|
|
publicTpl
|
|
|
|
SubUUID string
|
|
|
|
ListUUIDs []string `query:"l" form:"l"`
|
|
|
|
Lists []models.List `query:"-" form:"-"`
|
|
|
|
}
|
|
|
|
|
2019-07-18 07:14:46 +00:00
|
|
|
type msgTpl struct {
|
2018-10-25 13:51:47 +00:00
|
|
|
publicTpl
|
2019-07-18 07:14:46 +00:00
|
|
|
MessageTitle string
|
|
|
|
Message string
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2021-01-31 10:49:39 +00:00
|
|
|
type subFormTpl struct {
|
|
|
|
publicTpl
|
2023-01-23 16:20:10 +00:00
|
|
|
Lists []models.List
|
|
|
|
CaptchaKey string
|
2021-01-31 10:49:39 +00:00
|
|
|
}
|
|
|
|
|
2018-11-02 07:50:32 +00:00
|
|
|
var (
|
2019-07-21 14:41:11 +00:00
|
|
|
pixelPNG = drawTransparentImage(3, 14)
|
2018-11-02 07:50:32 +00:00
|
|
|
)
|
2018-10-25 13:51:47 +00:00
|
|
|
|
2018-10-31 12:52:54 +00:00
|
|
|
// Render executes and renders a template for echo.
|
2019-01-03 11:18:47 +00:00
|
|
|
func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
|
|
|
return t.templates.ExecuteTemplate(w, name, tplData{
|
2022-11-10 15:49:53 +00:00
|
|
|
SiteName: t.SiteName,
|
2022-11-09 16:54:34 +00:00
|
|
|
RootURL: t.RootURL,
|
|
|
|
LogoURL: t.LogoURL,
|
|
|
|
FaviconURL: t.FaviconURL,
|
|
|
|
EnablePublicSubPage: t.EnablePublicSubPage,
|
2022-11-10 16:54:15 +00:00
|
|
|
EnablePublicArchive: t.EnablePublicArchive,
|
2022-11-09 16:54:34 +00:00
|
|
|
Data: data,
|
|
|
|
L: c.Get("app").(*App).i18n,
|
2019-01-03 11:18:47 +00:00
|
|
|
})
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2022-08-28 09:42:20 +00:00
|
|
|
// handleGetPublicLists returns the list of public lists with minimal fields
|
|
|
|
// required to submit a subscription.
|
|
|
|
func handleGetPublicLists(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
)
|
|
|
|
|
|
|
|
// Get all public lists.
|
|
|
|
lists, err := app.core.GetLists(models.ListTypePublic)
|
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
|
|
|
}
|
|
|
|
|
|
|
|
type list struct {
|
|
|
|
UUID string `json:"uuid"`
|
|
|
|
Name string `json:"name"`
|
|
|
|
}
|
|
|
|
|
|
|
|
out := make([]list, 0, len(lists))
|
|
|
|
for _, l := range lists {
|
|
|
|
out = append(out, list{
|
|
|
|
UUID: l.UUID,
|
|
|
|
Name: l.Name,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, out)
|
|
|
|
}
|
|
|
|
|
2020-04-26 10:21:43 +00:00
|
|
|
// handleViewCampaignMessage renders the HTML view of a campaign message.
|
2020-05-19 17:59:58 +00:00
|
|
|
// This is the view the {{ MessageURL }} template tag links to in e-mail campaigns.
|
2020-04-26 10:21:43 +00:00
|
|
|
func handleViewCampaignMessage(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
campUUID = c.Param("campUUID")
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
|
|
|
|
|
|
|
// Get the campaign.
|
2022-04-03 15:24:40 +00:00
|
|
|
camp, err := app.core.GetCampaign(0, campUUID)
|
|
|
|
if err != nil {
|
|
|
|
if er, ok := err.(*echo.HTTPError); ok {
|
|
|
|
if er.Code == http.StatusBadRequest {
|
|
|
|
return c.Render(http.StatusNotFound, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.campaignNotFound")))
|
|
|
|
}
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Get the subscriber.
|
2022-04-03 15:24:40 +00:00
|
|
|
sub, err := app.core.GetSubscriber(0, subUUID, "")
|
2021-02-15 12:57:14 +00:00
|
|
|
if err != nil {
|
2020-04-26 10:21:43 +00:00
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
return c.Render(http.StatusNotFound, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.notFoundTitle"), "", app.i18n.T("public.errorFetchingEmail")))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Render the message body.
|
2021-05-21 18:05:08 +00:00
|
|
|
msg, err := app.manager.NewCampaignMessage(&camp, sub)
|
|
|
|
if err != nil {
|
2020-04-26 10:21:43 +00:00
|
|
|
app.log.Printf("error rendering message: %v", err)
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingCampaign")))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
2021-05-21 18:05:08 +00:00
|
|
|
return c.HTML(http.StatusOK, string(msg.Body()))
|
2020-04-26 10:21:43 +00:00
|
|
|
}
|
|
|
|
|
2019-07-21 13:48:41 +00:00
|
|
|
// handleSubscriptionPage renders the subscription management page and
|
2020-05-19 17:59:58 +00:00
|
|
|
// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
|
|
|
|
// campaigns link to.
|
2019-07-21 13:48:41 +00:00
|
|
|
func handleSubscriptionPage(c echo.Context) error {
|
2018-10-25 13:51:47 +00:00
|
|
|
var (
|
2022-10-18 16:14:57 +00:00
|
|
|
app = c.Get("app").(*App)
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
showManage, _ = strconv.ParseBool(c.FormValue("manage"))
|
|
|
|
out = unsubTpl{}
|
2018-10-25 13:51:47 +00:00
|
|
|
)
|
2019-07-21 13:48:41 +00:00
|
|
|
out.SubUUID = subUUID
|
2020-12-19 10:55:52 +00:00
|
|
|
out.Title = app.i18n.T("public.unsubscribeTitle")
|
2020-08-01 11:15:29 +00:00
|
|
|
out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
|
2020-03-07 18:33:22 +00:00
|
|
|
out.AllowExport = app.constants.Privacy.AllowExport
|
|
|
|
out.AllowWipe = app.constants.Privacy.AllowWipe
|
2022-10-18 16:14:57 +00:00
|
|
|
out.AllowPreferences = app.constants.Privacy.AllowPreferences
|
2018-10-25 13:51:47 +00:00
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
s, err := app.core.GetSubscriber(0, subUUID, "")
|
|
|
|
if err != nil {
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
|
|
|
}
|
|
|
|
out.Subscriber = s
|
|
|
|
|
|
|
|
if s.Status == models.SubscriberStatusBlockListed {
|
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.blocklisted")))
|
|
|
|
}
|
|
|
|
|
2023-09-19 10:35:45 +00:00
|
|
|
// Only show preference management if it's enabled in settings.
|
|
|
|
if app.constants.Privacy.AllowPreferences {
|
|
|
|
out.ShowManage = showManage
|
|
|
|
}
|
|
|
|
if out.ShowManage {
|
|
|
|
// Get the subscriber's lists.
|
|
|
|
subs, err := app.core.GetSubscriptions(0, subUUID, false)
|
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
|
|
|
}
|
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
out.Subscriptions = make([]models.Subscription, 0, len(subs))
|
|
|
|
for _, s := range subs {
|
|
|
|
if s.Type == models.ListTypePrivate {
|
2023-02-26 07:06:46 +00:00
|
|
|
continue
|
2022-10-18 16:14:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
out.Subscriptions = append(out.Subscriptions, s)
|
2019-07-21 14:13:43 +00:00
|
|
|
}
|
2022-10-18 16:14:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusOK, "subscription", out)
|
|
|
|
}
|
|
|
|
|
2023-06-20 18:10:13 +00:00
|
|
|
// handleSubscriptionPrefs renders the subscription management page and
|
2022-10-18 16:14:57 +00:00
|
|
|
// handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
|
|
|
|
// campaigns link to.
|
|
|
|
func handleSubscriptionPrefs(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
campUUID = c.Param("campUUID")
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
|
|
|
|
req struct {
|
|
|
|
Name string `form:"name" json:"name"`
|
|
|
|
ListUUIDs []string `form:"l" json:"list_uuids"`
|
|
|
|
Blocklist bool `form:"blocklist" json:"blocklist"`
|
|
|
|
Manage bool `form:"manage" json:"manage"`
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
// Read the form.
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidData")))
|
|
|
|
}
|
2019-07-21 14:13:43 +00:00
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
// Simple unsubscribe.
|
|
|
|
blocklist := app.constants.Privacy.AllowBlocklist && req.Blocklist
|
|
|
|
if !req.Manage || blocklist {
|
2022-04-03 15:24:40 +00:00
|
|
|
if err := app.core.UnsubscribeByCampaign(subUUID, campUUID, blocklist); err != nil {
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-10-18 16:14:57 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
2018-10-31 12:52:54 +00:00
|
|
|
}
|
2020-03-07 17:30:55 +00:00
|
|
|
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "", app.i18n.T("public.unsubbedInfo")))
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
// Is preference management enabled?
|
|
|
|
if !app.constants.Privacy.AllowPreferences {
|
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidFeature")))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Manage preferences.
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
|
|
if req.Name == "" || len(req.Name) > 256 {
|
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("subscribers.invalidName")))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the subscriber from the DB.
|
|
|
|
sub, err := app.core.GetSubscriber(0, subUUID, "")
|
|
|
|
if err != nil {
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("globals.messages.pFound",
|
|
|
|
"name", app.i18n.T("globals.terms.subscriber"))))
|
|
|
|
}
|
|
|
|
sub.Name = req.Name
|
|
|
|
|
|
|
|
// Update name.
|
|
|
|
if _, err := app.core.UpdateSubscriber(sub.ID, sub); err != nil {
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
|
|
|
}
|
|
|
|
|
|
|
|
// Get the subscriber's lists and whatever is not sent in the request (unchecked),
|
|
|
|
// unsubscribe them.
|
|
|
|
reqUUIDs := make(map[string]struct{})
|
|
|
|
for _, u := range req.ListUUIDs {
|
|
|
|
reqUUIDs[u] = struct{}{}
|
|
|
|
}
|
|
|
|
|
2023-08-06 15:39:16 +00:00
|
|
|
subs, err := app.core.GetSubscriptions(0, subUUID, false)
|
|
|
|
if err != nil {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.errorFetchingLists"))
|
|
|
|
}
|
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
unsubUUIDs := make([]string, 0, len(req.ListUUIDs))
|
|
|
|
for _, s := range subs {
|
2023-08-06 15:39:16 +00:00
|
|
|
if s.Type == models.ListTypePrivate {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
if _, ok := reqUUIDs[s.UUID]; !ok {
|
2022-10-18 16:14:57 +00:00
|
|
|
unsubUUIDs = append(unsubUUIDs, s.UUID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Unsubscribe from lists.
|
|
|
|
if err := app.core.UnsubscribeLists([]int{sub.ID}, nil, unsubUUIDs); err != nil {
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.errorProcessingRequest")))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("globals.messages.done"), "", app.i18n.T("public.prefsSaved")))
|
2018-10-25 13:51:47 +00:00
|
|
|
}
|
2018-10-31 12:52:54 +00:00
|
|
|
|
2020-05-19 17:59:58 +00:00
|
|
|
// handleOptinPage renders the double opt-in confirmation page that subscribers
|
|
|
|
// see when they click on the "Confirm subscription" button in double-optin
|
|
|
|
// notifications.
|
2019-12-01 12:18:36 +00:00
|
|
|
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 10:55:52 +00:00
|
|
|
out.Title = app.i18n.T("public.confirmOptinSubTitle")
|
2019-12-01 12:18:36 +00:00
|
|
|
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 14:54:42 +00:00
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("globals.messages.invalidUUID")))
|
2019-12-01 12:18:36 +00:00
|
|
|
}
|
|
|
|
}
|
2020-02-09 10:55:19 +00:00
|
|
|
}
|
2019-12-01 12:18:36 +00:00
|
|
|
|
2020-02-09 10:55:19 +00:00
|
|
|
// Get the list of subscription lists where the subscriber hasn't confirmed.
|
2022-04-03 15:24:40 +00:00
|
|
|
lists, err := app.core.GetSubscriberLists(0, subUUID, nil, out.ListUUIDs, models.SubscriptionStatusUnconfirmed, "")
|
|
|
|
if err != nil {
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))
|
2019-12-01 12:18:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// There are no lists to confirm.
|
2022-04-03 15:24:40 +00:00
|
|
|
if len(lists) == 0 {
|
2022-05-03 05:19:25 +00:00
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.noSubTitle"), "", app.i18n.Ts("public.noSubInfo")))
|
2019-12-01 12:18:36 +00:00
|
|
|
}
|
2022-09-10 06:47:01 +00:00
|
|
|
out.Lists = lists
|
2019-12-01 12:18:36 +00:00
|
|
|
|
|
|
|
// Confirm.
|
|
|
|
if confirm {
|
2023-07-22 07:58:45 +00:00
|
|
|
meta := models.JSON{}
|
|
|
|
if app.constants.Privacy.RecordOptinIP {
|
|
|
|
if h := c.Request().Header.Get("X-Forwarded-For"); h != "" {
|
|
|
|
meta["optin_ip"] = h
|
|
|
|
} else if h := c.Request().RemoteAddr; h != "" {
|
|
|
|
meta["optin_ip"] = strings.Split(h, ":")[0]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := app.core.ConfirmOptionSubscription(subUUID, out.ListUUIDs, meta); err != nil {
|
2020-03-07 18:33:22 +00:00
|
|
|
app.log.Printf("error unsubscribing: %v", err)
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
2019-12-01 12:18:36 +00:00
|
|
|
}
|
2020-12-19 10:55:52 +00:00
|
|
|
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "", app.i18n.Ts("public.subConfirmed")))
|
2019-12-01 12:18:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusOK, "optin", out)
|
|
|
|
}
|
|
|
|
|
2021-01-31 10:49:39 +00:00
|
|
|
// 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,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
|
2021-01-31 10:49:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Get all public lists.
|
2022-04-03 15:24:40 +00:00
|
|
|
lists, err := app.core.GetLists(models.ListTypePublic)
|
|
|
|
if err != nil {
|
2021-01-31 10:49:39 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorFetchingLists")))
|
2021-01-31 10:49:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
if len(lists) == 0 {
|
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.noListsAvailable")))
|
2021-01-31 10:49:39 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
out := subFormTpl{}
|
|
|
|
out.Title = app.i18n.T("public.sub")
|
|
|
|
out.Lists = lists
|
2021-06-05 07:15:10 +00:00
|
|
|
|
2023-01-23 16:20:10 +00:00
|
|
|
if app.constants.Security.EnableCaptcha {
|
|
|
|
out.CaptchaKey = app.constants.Security.CaptchaKey
|
|
|
|
}
|
|
|
|
|
2021-01-31 10:49:39 +00:00
|
|
|
return c.Render(http.StatusOK, "subscription-form", out)
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00:00
|
|
|
// handleSubscriptionForm handles subscription requests coming from public
|
|
|
|
// HTML subscription forms.
|
2020-03-07 14:49:22 +00:00
|
|
|
func handleSubscriptionForm(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
)
|
|
|
|
|
2021-04-21 08:31:32 +00:00
|
|
|
// If there's a nonce value, a bot could've filled the form.
|
|
|
|
if c.FormValue("nonce") != "" {
|
2022-08-28 09:42:20 +00:00
|
|
|
return echo.NewHTTPError(http.StatusBadGateway, app.i18n.T("public.invalidFeature"))
|
2022-04-03 15:24:40 +00:00
|
|
|
}
|
|
|
|
|
2023-01-23 16:20:10 +00:00
|
|
|
// Process CAPTCHA.
|
|
|
|
if app.constants.Security.EnableCaptcha {
|
|
|
|
err, ok := app.captcha.Verify(c.FormValue("h-captcha-response"))
|
|
|
|
if err != nil {
|
|
|
|
app.log.Printf("Captcha request failed: %v", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if !ok {
|
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.T("public.invalidCaptcha")))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-28 09:42:20 +00:00
|
|
|
hasOptin, err := processSubForm(c)
|
2022-04-03 15:24:40 +00:00
|
|
|
if err != nil {
|
2022-08-28 09:42:20 +00:00
|
|
|
e, ok := err.(*echo.HTTPError)
|
|
|
|
if !ok {
|
|
|
|
return e
|
2022-05-11 16:10:31 +00:00
|
|
|
}
|
|
|
|
|
2022-08-28 09:42:20 +00:00
|
|
|
return c.Render(e.Code, tplMessage,
|
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", e.Message)))
|
2020-03-07 14:49:22 +00:00
|
|
|
}
|
|
|
|
|
2022-08-28 09:42:20 +00:00
|
|
|
msg := "public.subConfirmed"
|
2021-02-13 12:25:10 +00:00
|
|
|
if hasOptin {
|
|
|
|
msg = "public.subOptinPending"
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
|
2020-03-07 14:49:22 +00:00
|
|
|
}
|
|
|
|
|
2023-06-20 18:10:13 +00:00
|
|
|
// handlePublicSubscription handles subscription requests coming from public
|
2022-08-28 09:42:20 +00:00
|
|
|
// API calls.
|
|
|
|
func handlePublicSubscription(c echo.Context) error {
|
2023-08-15 15:35:29 +00:00
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
)
|
|
|
|
|
|
|
|
if !app.constants.EnablePublicSubPage {
|
|
|
|
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("public.invalidFeature"))
|
|
|
|
}
|
|
|
|
|
2022-08-28 09:42:20 +00:00
|
|
|
hasOptin, err := processSubForm(c)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, okResp{struct {
|
|
|
|
HasOptin bool `json:"has_optin"`
|
|
|
|
}{hasOptin}})
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00: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.
|
2018-10-31 12:52:54 +00:00
|
|
|
func handleLinkRedirect(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
linkUUID = c.Param("linkUUID")
|
|
|
|
campUUID = c.Param("campUUID")
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
|
|
|
|
2020-10-18 12:03:34 +00:00
|
|
|
// If individual tracking is disabled, do not record the subscriber ID.
|
|
|
|
if !app.constants.Privacy.IndividualTracking {
|
|
|
|
subUUID = ""
|
|
|
|
}
|
|
|
|
|
2022-05-05 12:35:13 +00:00
|
|
|
url, err := app.core.RegisterCampaignLinkClick(linkUUID, campUUID, subUUID)
|
|
|
|
if err != nil {
|
2022-04-03 15:24:40 +00:00
|
|
|
e := err.(*echo.HTTPError)
|
|
|
|
return c.Render(e.Code, tplMessage, makeMsgTpl(app.i18n.T("public.errorTitle"), "", e.Error()))
|
2018-10-31 12:52:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return c.Redirect(http.StatusTemporaryRedirect, url)
|
|
|
|
}
|
2018-11-02 07:50:32 +00:00
|
|
|
|
|
|
|
// handleRegisterCampaignView registers a campaign view which comes in
|
|
|
|
// the form of an pixel image request. Regardless of errors, this handler
|
2020-05-19 17:59:58 +00:00
|
|
|
// should always render the pixel image bytes. The pixel URL is is generated by
|
|
|
|
// the {{ TrackView }} template tag in campaigns.
|
2018-11-02 07:50:32 +00:00
|
|
|
func handleRegisterCampaignView(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
campUUID = c.Param("campUUID")
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
2020-03-08 09:40:51 +00:00
|
|
|
|
2020-10-18 12:03:34 +00:00
|
|
|
// If individual tracking is disabled, do not record the subscriber ID.
|
|
|
|
if !app.constants.Privacy.IndividualTracking {
|
|
|
|
subUUID = ""
|
|
|
|
}
|
|
|
|
|
2020-03-08 09:40:51 +00:00
|
|
|
// Exclude dummy hits from template previews.
|
|
|
|
if campUUID != dummyUUID && subUUID != dummyUUID {
|
2022-04-03 15:24:40 +00:00
|
|
|
if err := app.core.RegisterCampaignView(campUUID, subUUID); err != nil {
|
2020-03-08 09:40:51 +00:00
|
|
|
app.log.Printf("error registering campaign view: %s", err)
|
|
|
|
}
|
2018-11-02 07:50:32 +00:00
|
|
|
}
|
2020-03-08 09:40:51 +00:00
|
|
|
|
2018-11-29 09:57:21 +00:00
|
|
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
2018-11-02 07:50:32 +00:00
|
|
|
return c.Blob(http.StatusOK, "image/png", pixelPNG)
|
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00: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.
|
2019-07-21 13:48:41 +00:00
|
|
|
func handleSelfExportSubscriberData(c echo.Context) error {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
subUUID = c.Param("subUUID")
|
|
|
|
)
|
|
|
|
// Is export allowed?
|
2020-03-07 18:33:22 +00:00
|
|
|
if !app.constants.Privacy.AllowExport {
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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".
|
2020-03-07 18:33:22 +00:00
|
|
|
data, b, err := exportSubscriberData(0, subUUID, app.constants.Privacy.Exportable, app)
|
2019-07-21 13:48:41 +00:00
|
|
|
if err != nil {
|
2020-03-07 18:33:22 +00:00
|
|
|
app.log.Printf("error exporting subscriber data: %s", err)
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00:00
|
|
|
// Prepare the attachment e-mail.
|
2019-12-01 12:18:36 +00:00
|
|
|
var msg bytes.Buffer
|
2021-10-28 14:39:06 +00:00
|
|
|
if err := app.notifTpls.tpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
|
2020-12-19 10:55:52 +00:00
|
|
|
app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00:00
|
|
|
// Send the data as a JSON attachment to the subscriber.
|
2020-08-01 12:24:51 +00:00
|
|
|
const fname = "data.json"
|
2023-05-08 17:13:25 +00:00
|
|
|
if err := app.messengers[emailMsgr].Push(models.Message{
|
2021-10-28 14:39:06 +00:00
|
|
|
ContentType: app.notifTpls.contentType,
|
|
|
|
From: app.constants.FromEmail,
|
|
|
|
To: []string{data.Email},
|
2023-02-20 15:03:44 +00:00
|
|
|
Subject: app.i18n.Ts("email.data.title"),
|
2021-10-28 14:39:06 +00:00
|
|
|
Body: msg.Bytes(),
|
2023-05-08 17:13:25 +00:00
|
|
|
Attachments: []models.Attachment{
|
2020-05-16 17:11:30 +00:00
|
|
|
{
|
2019-07-21 13:48:41 +00:00
|
|
|
Name: fname,
|
|
|
|
Content: b,
|
2023-05-18 11:25:59 +00:00
|
|
|
Header: manager.MakeAttachmentHeader(fname, "base64", "application/json"),
|
2019-07-21 13:48:41 +00:00
|
|
|
},
|
|
|
|
},
|
2020-08-01 12:24:51 +00:00
|
|
|
}); err != nil {
|
2020-03-07 18:33:22 +00:00
|
|
|
app.log.Printf("error e-mailing subscriber profile: %s", err)
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
2020-12-19 10:55:52 +00:00
|
|
|
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.dataSentTitle"), "", app.i18n.T("public.dataSent")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2020-05-19 17:59:58 +00:00
|
|
|
// handleWipeSubscriberData allows a subscriber to delete their data. The
|
2019-07-21 13:48:41 +00:00
|
|
|
// 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?
|
2020-06-05 03:55:45 +00:00
|
|
|
if !app.constants.Privacy.AllowWipe {
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusBadRequest, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.invalidFeature")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2022-04-03 15:24:40 +00:00
|
|
|
if err := app.core.DeleteSubscribers(nil, []string{subUUID}); err != nil {
|
2020-03-07 18:33:22 +00:00
|
|
|
app.log.Printf("error wiping subscriber data: %s", err)
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusInternalServerError, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.errorTitle"), "", app.i18n.Ts("public.errorProcessingRequest")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2020-03-07 14:54:42 +00:00
|
|
|
return c.Render(http.StatusOK, tplMessage,
|
2022-04-03 15:24:40 +00:00
|
|
|
makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "", app.i18n.T("public.dataRemoved")))
|
2019-07-21 13:48:41 +00:00
|
|
|
}
|
|
|
|
|
2018-11-02 07:50:32 +00: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{}
|
|
|
|
)
|
2019-10-25 05:41:47 +00:00
|
|
|
_ = png.Encode(out, img)
|
2018-11-02 07:50:32 +00:00
|
|
|
return out.Bytes()
|
|
|
|
}
|
2022-08-28 09:42:20 +00:00
|
|
|
|
|
|
|
// processSubForm processes an incoming form/public API subscription request.
|
|
|
|
// The bool indicates whether there was subscription to an optin list so that
|
|
|
|
// an appropriate message can be shown.
|
|
|
|
func processSubForm(c echo.Context) (bool, error) {
|
|
|
|
var (
|
|
|
|
app = c.Get("app").(*App)
|
|
|
|
req struct {
|
|
|
|
Name string `form:"name" json:"name"`
|
|
|
|
Email string `form:"email" json:"email"`
|
|
|
|
FormListUUIDs []string `form:"l" json:"list_uuids"`
|
|
|
|
}
|
|
|
|
)
|
|
|
|
|
|
|
|
// Get and validate fields.
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if len(req.FormListUUIDs) == 0 {
|
|
|
|
return false, echo.NewHTTPError(http.StatusBadRequest, 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 len(req.Email) > 1000 {
|
|
|
|
return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidEmail"))
|
|
|
|
}
|
|
|
|
|
|
|
|
em, err := app.importer.SanitizeEmail(req.Email)
|
|
|
|
if err != nil {
|
|
|
|
return false, echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
|
|
|
}
|
|
|
|
req.Email = em
|
|
|
|
|
|
|
|
req.Name = strings.TrimSpace(req.Name)
|
|
|
|
if len(req.Name) == 0 || len(req.Name) > stdInputMaxLen {
|
|
|
|
return false, echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("subscribers.invalidName"))
|
|
|
|
}
|
|
|
|
|
|
|
|
listUUIDs := pq.StringArray(req.FormListUUIDs)
|
|
|
|
|
|
|
|
// Insert the subscriber into the DB.
|
|
|
|
_, hasOptin, err := app.core.InsertSubscriber(models.Subscriber{
|
|
|
|
Name: req.Name,
|
|
|
|
Email: req.Email,
|
|
|
|
Status: models.SubscriberStatusEnabled,
|
|
|
|
}, nil, listUUIDs, false)
|
|
|
|
if err != nil {
|
|
|
|
// Subscriber already exists. Update subscriptions.
|
|
|
|
if e, ok := err.(*echo.HTTPError); ok && e.Code == http.StatusConflict {
|
|
|
|
sub, err := app.core.GetSubscriber(0, "", req.Email)
|
|
|
|
if err != nil {
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
|
2022-10-18 16:14:57 +00:00
|
|
|
if _, err := app.core.UpdateSubscriberWithLists(sub.ID, sub, nil, listUUIDs, false, false); err != nil {
|
2022-08-28 09:42:20 +00:00
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return false, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%s", err.(*echo.HTTPError).Message))
|
|
|
|
}
|
|
|
|
|
|
|
|
return hasOptin, nil
|
|
|
|
}
|