public.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510
  1. package main
  2. import (
  3. "bytes"
  4. "database/sql"
  5. "fmt"
  6. "html/template"
  7. "image"
  8. "image/png"
  9. "io"
  10. "net/http"
  11. "strconv"
  12. "strings"
  13. "github.com/knadh/listmonk/internal/i18n"
  14. "github.com/knadh/listmonk/internal/messenger"
  15. "github.com/knadh/listmonk/internal/subimporter"
  16. "github.com/knadh/listmonk/models"
  17. "github.com/labstack/echo"
  18. "github.com/lib/pq"
  19. )
  20. const (
  21. tplMessage = "message"
  22. )
  23. // tplRenderer wraps a template.tplRenderer for echo.
  24. type tplRenderer struct {
  25. templates *template.Template
  26. RootURL string
  27. LogoURL string
  28. FaviconURL string
  29. }
  30. // tplData is the data container that is injected
  31. // into public templates for accessing data.
  32. type tplData struct {
  33. RootURL string
  34. LogoURL string
  35. FaviconURL string
  36. Data interface{}
  37. L *i18n.I18n
  38. }
  39. type publicTpl struct {
  40. Title string
  41. Description string
  42. }
  43. type unsubTpl struct {
  44. publicTpl
  45. SubUUID string
  46. AllowBlocklist bool
  47. AllowExport bool
  48. AllowWipe bool
  49. }
  50. type optinTpl struct {
  51. publicTpl
  52. SubUUID string
  53. ListUUIDs []string `query:"l" form:"l"`
  54. Lists []models.List `query:"-" form:"-"`
  55. }
  56. type msgTpl struct {
  57. publicTpl
  58. MessageTitle string
  59. Message string
  60. }
  61. type subFormTpl struct {
  62. publicTpl
  63. Lists []models.List
  64. }
  65. type subForm struct {
  66. subimporter.SubReq
  67. SubListUUIDs []string `form:"l"`
  68. }
  69. var (
  70. pixelPNG = drawTransparentImage(3, 14)
  71. )
  72. // Render executes and renders a template for echo.
  73. func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
  74. return t.templates.ExecuteTemplate(w, name, tplData{
  75. RootURL: t.RootURL,
  76. LogoURL: t.LogoURL,
  77. FaviconURL: t.FaviconURL,
  78. Data: data,
  79. L: c.Get("app").(*App).i18n,
  80. })
  81. }
  82. // handleViewCampaignMessage renders the HTML view of a campaign message.
  83. // This is the view the {{ MessageURL }} template tag links to in e-mail campaigns.
  84. func handleViewCampaignMessage(c echo.Context) error {
  85. var (
  86. app = c.Get("app").(*App)
  87. campUUID = c.Param("campUUID")
  88. subUUID = c.Param("subUUID")
  89. )
  90. // Get the campaign.
  91. var camp models.Campaign
  92. if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
  93. if err == sql.ErrNoRows {
  94. return c.Render(http.StatusNotFound, tplMessage,
  95. makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
  96. app.i18n.T("public.campaignNotFound")))
  97. }
  98. app.log.Printf("error fetching campaign: %v", err)
  99. return c.Render(http.StatusInternalServerError, tplMessage,
  100. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  101. app.i18n.Ts("public.errorFetchingCampaign")))
  102. }
  103. // Get the subscriber.
  104. sub, err := getSubscriber(0, subUUID, "", app)
  105. if err != nil {
  106. if err == sql.ErrNoRows {
  107. return c.Render(http.StatusNotFound, tplMessage,
  108. makeMsgTpl(app.i18n.T("public.notFoundTitle"), "",
  109. app.i18n.T("public.errorFetchingEmail")))
  110. }
  111. return c.Render(http.StatusInternalServerError, tplMessage,
  112. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  113. app.i18n.Ts("public.errorFetchingCampaign")))
  114. }
  115. // Compile the template.
  116. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
  117. app.log.Printf("error compiling template: %v", err)
  118. return c.Render(http.StatusInternalServerError, tplMessage,
  119. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  120. app.i18n.Ts("public.errorFetchingCampaign")))
  121. }
  122. // Render the message body.
  123. msg, err := app.manager.NewCampaignMessage(&camp, sub)
  124. if err != nil {
  125. app.log.Printf("error rendering message: %v", err)
  126. return c.Render(http.StatusInternalServerError, tplMessage,
  127. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  128. app.i18n.Ts("public.errorFetchingCampaign")))
  129. }
  130. return c.HTML(http.StatusOK, string(msg.Body()))
  131. }
  132. // handleSubscriptionPage renders the subscription management page and
  133. // handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
  134. // campaigns link to.
  135. func handleSubscriptionPage(c echo.Context) error {
  136. var (
  137. app = c.Get("app").(*App)
  138. campUUID = c.Param("campUUID")
  139. subUUID = c.Param("subUUID")
  140. unsub = c.Request().Method == http.MethodPost
  141. blocklist, _ = strconv.ParseBool(c.FormValue("blocklist"))
  142. out = unsubTpl{}
  143. )
  144. out.SubUUID = subUUID
  145. out.Title = app.i18n.T("public.unsubscribeTitle")
  146. out.AllowBlocklist = app.constants.Privacy.AllowBlocklist
  147. out.AllowExport = app.constants.Privacy.AllowExport
  148. out.AllowWipe = app.constants.Privacy.AllowWipe
  149. // Unsubscribe.
  150. if unsub {
  151. // Is blocklisting allowed?
  152. if !app.constants.Privacy.AllowBlocklist {
  153. blocklist = false
  154. }
  155. if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blocklist); err != nil {
  156. app.log.Printf("error unsubscribing: %v", err)
  157. return c.Render(http.StatusInternalServerError, tplMessage,
  158. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  159. app.i18n.Ts("public.errorProcessingRequest")))
  160. }
  161. return c.Render(http.StatusOK, tplMessage,
  162. makeMsgTpl(app.i18n.T("public.unsubbedTitle"), "",
  163. app.i18n.T("public.unsubbedInfo")))
  164. }
  165. return c.Render(http.StatusOK, "subscription", out)
  166. }
  167. // handleOptinPage renders the double opt-in confirmation page that subscribers
  168. // see when they click on the "Confirm subscription" button in double-optin
  169. // notifications.
  170. func handleOptinPage(c echo.Context) error {
  171. var (
  172. app = c.Get("app").(*App)
  173. subUUID = c.Param("subUUID")
  174. confirm, _ = strconv.ParseBool(c.FormValue("confirm"))
  175. out = optinTpl{}
  176. )
  177. out.SubUUID = subUUID
  178. out.Title = app.i18n.T("public.confirmOptinSubTitle")
  179. out.SubUUID = subUUID
  180. // Get and validate fields.
  181. if err := c.Bind(&out); err != nil {
  182. return err
  183. }
  184. // Validate list UUIDs if there are incoming UUIDs in the request.
  185. if len(out.ListUUIDs) > 0 {
  186. for _, l := range out.ListUUIDs {
  187. if !reUUID.MatchString(l) {
  188. return c.Render(http.StatusBadRequest, tplMessage,
  189. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  190. app.i18n.T("globals.messages.invalidUUID")))
  191. }
  192. }
  193. }
  194. // Get the list of subscription lists where the subscriber hasn't confirmed.
  195. if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
  196. nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
  197. app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
  198. return c.Render(http.StatusInternalServerError, tplMessage,
  199. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  200. app.i18n.Ts("public.errorFetchingLists")))
  201. }
  202. // There are no lists to confirm.
  203. if len(out.Lists) == 0 {
  204. return c.Render(http.StatusInternalServerError, tplMessage,
  205. makeMsgTpl(app.i18n.T("public.noSubTitle"), "",
  206. app.i18n.Ts("public.noSubInfo")))
  207. }
  208. // Confirm.
  209. if confirm {
  210. if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
  211. app.log.Printf("error unsubscribing: %v", err)
  212. return c.Render(http.StatusInternalServerError, tplMessage,
  213. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  214. app.i18n.Ts("public.errorProcessingRequest")))
  215. }
  216. return c.Render(http.StatusOK, tplMessage,
  217. makeMsgTpl(app.i18n.T("public.subConfirmedTitle"), "",
  218. app.i18n.Ts("public.subConfirmed")))
  219. }
  220. return c.Render(http.StatusOK, "optin", out)
  221. }
  222. // handleSubscriptionFormPage handles subscription requests coming from public
  223. // HTML subscription forms.
  224. func handleSubscriptionFormPage(c echo.Context) error {
  225. var (
  226. app = c.Get("app").(*App)
  227. )
  228. if !app.constants.EnablePublicSubPage {
  229. return c.Render(http.StatusNotFound, tplMessage,
  230. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  231. app.i18n.Ts("public.invalidFeature")))
  232. }
  233. // Get all public lists.
  234. var lists []models.List
  235. if err := app.queries.GetLists.Select(&lists, models.ListTypePublic, "name"); err != nil {
  236. app.log.Printf("error fetching public lists for form: %s", pqErrMsg(err))
  237. return c.Render(http.StatusInternalServerError, tplMessage,
  238. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  239. app.i18n.Ts("public.errorFetchingLists")))
  240. }
  241. if len(lists) == 0 {
  242. return c.Render(http.StatusInternalServerError, tplMessage,
  243. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  244. app.i18n.Ts("public.noListsAvailable")))
  245. }
  246. out := subFormTpl{}
  247. out.Title = app.i18n.T("public.sub")
  248. out.Lists = lists
  249. return c.Render(http.StatusOK, "subscription-form", out)
  250. }
  251. // handleSubscriptionForm handles subscription requests coming from public
  252. // HTML subscription forms.
  253. func handleSubscriptionForm(c echo.Context) error {
  254. var (
  255. app = c.Get("app").(*App)
  256. req subForm
  257. )
  258. // Get and validate fields.
  259. if err := c.Bind(&req); err != nil {
  260. return err
  261. }
  262. // If there's a nonce value, a bot could've filled the form.
  263. if c.FormValue("nonce") != "" {
  264. return c.Render(http.StatusOK, tplMessage,
  265. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  266. app.i18n.T("public.invalidFeature")))
  267. }
  268. if len(req.SubListUUIDs) == 0 {
  269. return c.Render(http.StatusBadRequest, tplMessage,
  270. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  271. app.i18n.T("public.noListsSelected")))
  272. }
  273. // If there's no name, use the name bit from the e-mail.
  274. req.Name = strings.TrimSpace(req.Name)
  275. if req.Name == "" {
  276. req.Name = strings.Split(req.Email, "@")[0]
  277. }
  278. // Validate fields.
  279. if r, err := app.importer.ValidateFields(req.SubReq); err != nil {
  280. return c.Render(http.StatusInternalServerError, tplMessage,
  281. makeMsgTpl(app.i18n.T("public.errorTitle"), "", err.Error()))
  282. } else {
  283. req.SubReq = r
  284. }
  285. // Insert the subscriber into the DB.
  286. req.Status = models.SubscriberStatusEnabled
  287. req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
  288. _, _, hasOptin, err := insertSubscriber(req.SubReq, app)
  289. if err != nil {
  290. return c.Render(http.StatusInternalServerError, tplMessage,
  291. makeMsgTpl(app.i18n.T("public.errorTitle"), "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
  292. }
  293. msg := "public.subConfirmed"
  294. if hasOptin {
  295. msg = "public.subOptinPending"
  296. }
  297. return c.Render(http.StatusOK, tplMessage, makeMsgTpl(app.i18n.T("public.subTitle"), "", app.i18n.Ts(msg)))
  298. }
  299. // handleLinkRedirect redirects a link UUID to its original underlying link
  300. // after recording the link click for a particular subscriber in the particular
  301. // campaign. These links are generated by {{ TrackLink }} tags in campaigns.
  302. func handleLinkRedirect(c echo.Context) error {
  303. var (
  304. app = c.Get("app").(*App)
  305. linkUUID = c.Param("linkUUID")
  306. campUUID = c.Param("campUUID")
  307. subUUID = c.Param("subUUID")
  308. )
  309. // If individual tracking is disabled, do not record the subscriber ID.
  310. if !app.constants.Privacy.IndividualTracking {
  311. subUUID = ""
  312. }
  313. var url string
  314. if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
  315. if pqErr, ok := err.(*pq.Error); ok && pqErr.Column == "link_id" {
  316. return c.Render(http.StatusNotFound, tplMessage,
  317. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  318. app.i18n.Ts("public.invalidLink")))
  319. }
  320. app.log.Printf("error fetching redirect link: %s", err)
  321. return c.Render(http.StatusInternalServerError, tplMessage,
  322. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  323. app.i18n.Ts("public.errorProcessingRequest")))
  324. }
  325. return c.Redirect(http.StatusTemporaryRedirect, url)
  326. }
  327. // handleRegisterCampaignView registers a campaign view which comes in
  328. // the form of an pixel image request. Regardless of errors, this handler
  329. // should always render the pixel image bytes. The pixel URL is is generated by
  330. // the {{ TrackView }} template tag in campaigns.
  331. func handleRegisterCampaignView(c echo.Context) error {
  332. var (
  333. app = c.Get("app").(*App)
  334. campUUID = c.Param("campUUID")
  335. subUUID = c.Param("subUUID")
  336. )
  337. // If individual tracking is disabled, do not record the subscriber ID.
  338. if !app.constants.Privacy.IndividualTracking {
  339. subUUID = ""
  340. }
  341. // Exclude dummy hits from template previews.
  342. if campUUID != dummyUUID && subUUID != dummyUUID {
  343. if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
  344. app.log.Printf("error registering campaign view: %s", err)
  345. }
  346. }
  347. c.Response().Header().Set("Cache-Control", "no-cache")
  348. return c.Blob(http.StatusOK, "image/png", pixelPNG)
  349. }
  350. // handleSelfExportSubscriberData pulls the subscriber's profile, list subscriptions,
  351. // campaign views and clicks and produces a JSON report that is then e-mailed
  352. // to the subscriber. This is a privacy feature and the data that's exported
  353. // is dependent on the configuration.
  354. func handleSelfExportSubscriberData(c echo.Context) error {
  355. var (
  356. app = c.Get("app").(*App)
  357. subUUID = c.Param("subUUID")
  358. )
  359. // Is export allowed?
  360. if !app.constants.Privacy.AllowExport {
  361. return c.Render(http.StatusBadRequest, tplMessage,
  362. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  363. app.i18n.Ts("public.invalidFeature")))
  364. }
  365. // Get the subscriber's data. A single query that gets the profile,
  366. // list subscriptions, campaign views, and link clicks. Names of
  367. // private lists are replaced with "Private list".
  368. data, b, err := exportSubscriberData(0, subUUID, app.constants.Privacy.Exportable, app)
  369. if err != nil {
  370. app.log.Printf("error exporting subscriber data: %s", err)
  371. return c.Render(http.StatusInternalServerError, tplMessage,
  372. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  373. app.i18n.Ts("public.errorProcessingRequest")))
  374. }
  375. // Prepare the attachment e-mail.
  376. var msg bytes.Buffer
  377. if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
  378. app.log.Printf("error compiling notification template '%s': %v", notifSubscriberData, err)
  379. return c.Render(http.StatusInternalServerError, tplMessage,
  380. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  381. app.i18n.Ts("public.errorProcessingRequest")))
  382. }
  383. // Send the data as a JSON attachment to the subscriber.
  384. const fname = "data.json"
  385. if err := app.messengers[emailMsgr].Push(messenger.Message{
  386. From: app.constants.FromEmail,
  387. To: []string{data.Email},
  388. Subject: "Your data",
  389. Body: msg.Bytes(),
  390. Attachments: []messenger.Attachment{
  391. {
  392. Name: fname,
  393. Content: b,
  394. Header: messenger.MakeAttachmentHeader(fname, "base64"),
  395. },
  396. },
  397. }); err != nil {
  398. app.log.Printf("error e-mailing subscriber profile: %s", err)
  399. return c.Render(http.StatusInternalServerError, tplMessage,
  400. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  401. app.i18n.Ts("public.errorProcessingRequest")))
  402. }
  403. return c.Render(http.StatusOK, tplMessage,
  404. makeMsgTpl(app.i18n.T("public.dataSentTitle"), "",
  405. app.i18n.T("public.dataSent")))
  406. }
  407. // handleWipeSubscriberData allows a subscriber to delete their data. The
  408. // profile and subscriptions are deleted, while the campaign_views and link
  409. // clicks remain as orphan data unconnected to any subscriber.
  410. func handleWipeSubscriberData(c echo.Context) error {
  411. var (
  412. app = c.Get("app").(*App)
  413. subUUID = c.Param("subUUID")
  414. )
  415. // Is wiping allowed?
  416. if !app.constants.Privacy.AllowWipe {
  417. return c.Render(http.StatusBadRequest, tplMessage,
  418. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  419. app.i18n.Ts("public.invalidFeature")))
  420. }
  421. if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
  422. app.log.Printf("error wiping subscriber data: %s", err)
  423. return c.Render(http.StatusInternalServerError, tplMessage,
  424. makeMsgTpl(app.i18n.T("public.errorTitle"), "",
  425. app.i18n.Ts("public.errorProcessingRequest")))
  426. }
  427. return c.Render(http.StatusOK, tplMessage,
  428. makeMsgTpl(app.i18n.T("public.dataRemovedTitle"), "",
  429. app.i18n.T("public.dataRemoved")))
  430. }
  431. // drawTransparentImage draws a transparent PNG of given dimensions
  432. // and returns the PNG bytes.
  433. func drawTransparentImage(h, w int) []byte {
  434. var (
  435. img = image.NewRGBA(image.Rect(0, 0, w, h))
  436. out = &bytes.Buffer{}
  437. )
  438. _ = png.Encode(out, img)
  439. return out.Bytes()
  440. }