public.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  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/messenger"
  14. "github.com/knadh/listmonk/internal/subimporter"
  15. "github.com/knadh/listmonk/models"
  16. "github.com/labstack/echo"
  17. "github.com/lib/pq"
  18. )
  19. const (
  20. tplMessage = "message"
  21. )
  22. // tplRenderer wraps a template.tplRenderer for echo.
  23. type tplRenderer struct {
  24. templates *template.Template
  25. RootURL string
  26. LogoURL string
  27. FaviconURL string
  28. }
  29. // tplData is the data container that is injected
  30. // into public templates for accessing data.
  31. type tplData struct {
  32. RootURL string
  33. LogoURL string
  34. FaviconURL string
  35. Data interface{}
  36. }
  37. type publicTpl struct {
  38. Title string
  39. Description string
  40. }
  41. type unsubTpl struct {
  42. publicTpl
  43. SubUUID string
  44. AllowBlacklist bool
  45. AllowExport bool
  46. AllowWipe bool
  47. }
  48. type optinTpl struct {
  49. publicTpl
  50. SubUUID string
  51. ListUUIDs []string `query:"l" form:"l"`
  52. Lists []models.List `query:"-" form:"-"`
  53. }
  54. type msgTpl struct {
  55. publicTpl
  56. MessageTitle string
  57. Message string
  58. }
  59. type subForm struct {
  60. subimporter.SubReq
  61. SubListUUIDs []string `form:"l"`
  62. }
  63. var (
  64. pixelPNG = drawTransparentImage(3, 14)
  65. )
  66. // Render executes and renders a template for echo.
  67. func (t *tplRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
  68. return t.templates.ExecuteTemplate(w, name, tplData{
  69. RootURL: t.RootURL,
  70. LogoURL: t.LogoURL,
  71. FaviconURL: t.FaviconURL,
  72. Data: data,
  73. })
  74. }
  75. // handleViewCampaignMessage renders the HTML view of a campaign message.
  76. // This is the view the {{ MessageURL }} template tag links to in e-mail campaigns.
  77. func handleViewCampaignMessage(c echo.Context) error {
  78. var (
  79. app = c.Get("app").(*App)
  80. campUUID = c.Param("campUUID")
  81. subUUID = c.Param("subUUID")
  82. )
  83. // Get the campaign.
  84. var camp models.Campaign
  85. if err := app.queries.GetCampaign.Get(&camp, 0, campUUID); err != nil {
  86. if err == sql.ErrNoRows {
  87. return c.Render(http.StatusNotFound, tplMessage,
  88. makeMsgTpl("Not found", "", `The e-mail campaign was not found.`))
  89. }
  90. app.log.Printf("error fetching campaign: %v", err)
  91. return c.Render(http.StatusInternalServerError, tplMessage,
  92. makeMsgTpl("Error", "", `Error fetching e-mail campaign.`))
  93. }
  94. // Get the subscriber.
  95. var sub models.Subscriber
  96. if err := app.queries.GetSubscriber.Get(&sub, 0, subUUID); err != nil {
  97. if err == sql.ErrNoRows {
  98. return c.Render(http.StatusNotFound, tplMessage,
  99. makeMsgTpl("Not found", "", `The e-mail message was not found.`))
  100. }
  101. app.log.Printf("error fetching campaign subscriber: %v", err)
  102. return c.Render(http.StatusInternalServerError, tplMessage,
  103. makeMsgTpl("Error", "", `Error fetching e-mail message.`))
  104. }
  105. // Compile the template.
  106. if err := camp.CompileTemplate(app.manager.TemplateFuncs(&camp)); err != nil {
  107. app.log.Printf("error compiling template: %v", err)
  108. return c.Render(http.StatusInternalServerError, tplMessage,
  109. makeMsgTpl("Error", "", `Error compiling e-mail template.`))
  110. }
  111. // Render the message body.
  112. m := app.manager.NewCampaignMessage(&camp, sub)
  113. if err := m.Render(); err != nil {
  114. app.log.Printf("error rendering message: %v", err)
  115. return c.Render(http.StatusInternalServerError, tplMessage,
  116. makeMsgTpl("Error", "", `Error rendering e-mail message.`))
  117. }
  118. return c.HTML(http.StatusOK, string(m.Body()))
  119. }
  120. // handleSubscriptionPage renders the subscription management page and
  121. // handles unsubscriptions. This is the view that {{ UnsubscribeURL }} in
  122. // campaigns link to.
  123. func handleSubscriptionPage(c echo.Context) error {
  124. var (
  125. app = c.Get("app").(*App)
  126. campUUID = c.Param("campUUID")
  127. subUUID = c.Param("subUUID")
  128. unsub, _ = strconv.ParseBool(c.FormValue("unsubscribe"))
  129. blacklist, _ = strconv.ParseBool(c.FormValue("blacklist"))
  130. out = unsubTpl{}
  131. )
  132. out.SubUUID = subUUID
  133. out.Title = "Unsubscribe from mailing list"
  134. out.AllowBlacklist = app.constants.Privacy.AllowBlacklist
  135. out.AllowExport = app.constants.Privacy.AllowExport
  136. out.AllowWipe = app.constants.Privacy.AllowWipe
  137. // Unsubscribe.
  138. if unsub {
  139. // Is blacklisting allowed?
  140. if !app.constants.Privacy.AllowBlacklist {
  141. blacklist = false
  142. }
  143. if _, err := app.queries.Unsubscribe.Exec(campUUID, subUUID, blacklist); err != nil {
  144. app.log.Printf("error unsubscribing: %v", err)
  145. return c.Render(http.StatusInternalServerError, tplMessage,
  146. makeMsgTpl("Error", "",
  147. `Error processing request. Please retry.`))
  148. }
  149. return c.Render(http.StatusOK, tplMessage,
  150. makeMsgTpl("Unsubscribed", "",
  151. `You have been successfully unsubscribed.`))
  152. }
  153. return c.Render(http.StatusOK, "subscription", out)
  154. }
  155. // handleOptinPage renders the double opt-in confirmation page that subscribers
  156. // see when they click on the "Confirm subscription" button in double-optin
  157. // notifications.
  158. func handleOptinPage(c echo.Context) error {
  159. var (
  160. app = c.Get("app").(*App)
  161. subUUID = c.Param("subUUID")
  162. confirm, _ = strconv.ParseBool(c.FormValue("confirm"))
  163. out = optinTpl{}
  164. )
  165. out.SubUUID = subUUID
  166. out.Title = "Confirm subscriptions"
  167. out.SubUUID = subUUID
  168. // Get and validate fields.
  169. if err := c.Bind(&out); err != nil {
  170. return err
  171. }
  172. // Validate list UUIDs if there are incoming UUIDs in the request.
  173. if len(out.ListUUIDs) > 0 {
  174. for _, l := range out.ListUUIDs {
  175. if !reUUID.MatchString(l) {
  176. return c.Render(http.StatusBadRequest, tplMessage,
  177. makeMsgTpl("Invalid request", "",
  178. `One or more UUIDs in the request are invalid.`))
  179. }
  180. }
  181. }
  182. // Get the list of subscription lists where the subscriber hasn't confirmed.
  183. if err := app.queries.GetSubscriberLists.Select(&out.Lists, 0, subUUID,
  184. nil, pq.StringArray(out.ListUUIDs), models.SubscriptionStatusUnconfirmed, nil); err != nil {
  185. app.log.Printf("error fetching lists for opt-in: %s", pqErrMsg(err))
  186. return c.Render(http.StatusInternalServerError, tplMessage,
  187. makeMsgTpl("Error", "", `Error fetching lists. Please retry.`))
  188. }
  189. // There are no lists to confirm.
  190. if len(out.Lists) == 0 {
  191. return c.Render(http.StatusInternalServerError, tplMessage,
  192. makeMsgTpl("No subscriptions", "",
  193. `There are no subscriptions to confirm.`))
  194. }
  195. // Confirm.
  196. if confirm {
  197. if _, err := app.queries.ConfirmSubscriptionOptin.Exec(subUUID, pq.StringArray(out.ListUUIDs)); err != nil {
  198. app.log.Printf("error unsubscribing: %v", err)
  199. return c.Render(http.StatusInternalServerError, tplMessage,
  200. makeMsgTpl("Error", "",
  201. `Error processing request. Please retry.`))
  202. }
  203. return c.Render(http.StatusOK, tplMessage,
  204. makeMsgTpl("Confirmed", "",
  205. `Your subscriptions have been confirmed.`))
  206. }
  207. return c.Render(http.StatusOK, "optin", out)
  208. }
  209. // handleSubscriptionForm handles subscription requests coming from public
  210. // HTML subscription forms.
  211. func handleSubscriptionForm(c echo.Context) error {
  212. var (
  213. app = c.Get("app").(*App)
  214. req subForm
  215. )
  216. // Get and validate fields.
  217. if err := c.Bind(&req); err != nil {
  218. return err
  219. }
  220. if len(req.SubListUUIDs) == 0 {
  221. return c.Render(http.StatusInternalServerError, tplMessage,
  222. makeMsgTpl("Error", "",
  223. `No lists to subscribe to.`))
  224. }
  225. // If there's no name, use the name bit from the e-mail.
  226. req.Email = strings.ToLower(req.Email)
  227. if req.Name == "" {
  228. req.Name = strings.Split(req.Email, "@")[0]
  229. }
  230. // Validate fields.
  231. if err := subimporter.ValidateFields(req.SubReq); err != nil {
  232. return c.Render(http.StatusInternalServerError, tplMessage,
  233. makeMsgTpl("Error", "", err.Error()))
  234. }
  235. // Insert the subscriber into the DB.
  236. req.Status = models.SubscriberStatusEnabled
  237. req.ListUUIDs = pq.StringArray(req.SubListUUIDs)
  238. if _, err := insertSubscriber(req.SubReq, app); err != nil {
  239. return c.Render(http.StatusInternalServerError, tplMessage,
  240. makeMsgTpl("Error", "", fmt.Sprintf("%s", err.(*echo.HTTPError).Message)))
  241. }
  242. return c.Render(http.StatusInternalServerError, tplMessage,
  243. makeMsgTpl("Done", "", `Subscribed successfully.`))
  244. }
  245. // handleLinkRedirect redirects a link UUID to its original underlying link
  246. // after recording the link click for a particular subscriber in the particular
  247. // campaign. These links are generated by {{ TrackLink }} tags in campaigns.
  248. func handleLinkRedirect(c echo.Context) error {
  249. var (
  250. app = c.Get("app").(*App)
  251. linkUUID = c.Param("linkUUID")
  252. campUUID = c.Param("campUUID")
  253. subUUID = c.Param("subUUID")
  254. )
  255. var url string
  256. if err := app.queries.RegisterLinkClick.Get(&url, linkUUID, campUUID, subUUID); err != nil {
  257. if err != sql.ErrNoRows {
  258. app.log.Printf("error fetching redirect link: %s", err)
  259. }
  260. return c.Render(http.StatusInternalServerError, tplMessage,
  261. makeMsgTpl("Error opening link", "",
  262. "There was an error opening the link. Please try later."))
  263. }
  264. return c.Redirect(http.StatusTemporaryRedirect, url)
  265. }
  266. // handleRegisterCampaignView registers a campaign view which comes in
  267. // the form of an pixel image request. Regardless of errors, this handler
  268. // should always render the pixel image bytes. The pixel URL is is generated by
  269. // the {{ TrackView }} template tag in campaigns.
  270. func handleRegisterCampaignView(c echo.Context) error {
  271. var (
  272. app = c.Get("app").(*App)
  273. campUUID = c.Param("campUUID")
  274. subUUID = c.Param("subUUID")
  275. )
  276. // Exclude dummy hits from template previews.
  277. if campUUID != dummyUUID && subUUID != dummyUUID {
  278. if _, err := app.queries.RegisterCampaignView.Exec(campUUID, subUUID); err != nil {
  279. app.log.Printf("error registering campaign view: %s", err)
  280. }
  281. }
  282. c.Response().Header().Set("Cache-Control", "no-cache")
  283. return c.Blob(http.StatusOK, "image/png", pixelPNG)
  284. }
  285. // handleSelfExportSubscriberData pulls the subscriber's profile, list subscriptions,
  286. // campaign views and clicks and produces a JSON report that is then e-mailed
  287. // to the subscriber. This is a privacy feature and the data that's exported
  288. // is dependent on the configuration.
  289. func handleSelfExportSubscriberData(c echo.Context) error {
  290. var (
  291. app = c.Get("app").(*App)
  292. subUUID = c.Param("subUUID")
  293. )
  294. // Is export allowed?
  295. if !app.constants.Privacy.AllowExport {
  296. return c.Render(http.StatusBadRequest, tplMessage,
  297. makeMsgTpl("Invalid request", "", "The feature is not available."))
  298. }
  299. // Get the subscriber's data. A single query that gets the profile,
  300. // list subscriptions, campaign views, and link clicks. Names of
  301. // private lists are replaced with "Private list".
  302. data, b, err := exportSubscriberData(0, subUUID, app.constants.Privacy.Exportable, app)
  303. if err != nil {
  304. app.log.Printf("error exporting subscriber data: %s", err)
  305. return c.Render(http.StatusInternalServerError, tplMessage,
  306. makeMsgTpl("Error processing request", "",
  307. "There was an error processing your request. Please try later."))
  308. }
  309. // Prepare the attachment e-mail.
  310. var msg bytes.Buffer
  311. if err := app.notifTpls.ExecuteTemplate(&msg, notifSubscriberData, data); err != nil {
  312. app.log.Printf("error compiling notification template '%s': %v",
  313. notifSubscriberData, err)
  314. return c.Render(http.StatusInternalServerError, tplMessage,
  315. makeMsgTpl("Error preparing data", "",
  316. "There was an error preparing your data. Please try later."))
  317. }
  318. // Send the data as a JSON attachment to the subscriber.
  319. const fname = "profile.json"
  320. if err := app.messenger.Push(app.constants.FromEmail,
  321. []string{data.Email},
  322. "Your profile data",
  323. msg.Bytes(),
  324. []messenger.Attachment{
  325. {
  326. Name: fname,
  327. Content: b,
  328. Header: messenger.MakeAttachmentHeader(fname, "base64"),
  329. },
  330. },
  331. ); err != nil {
  332. app.log.Printf("error e-mailing subscriber profile: %s", err)
  333. return c.Render(http.StatusInternalServerError, tplMessage,
  334. makeMsgTpl("Error e-mailing data", "",
  335. "There was an error e-mailing your data. Please try later."))
  336. }
  337. return c.Render(http.StatusOK, tplMessage,
  338. makeMsgTpl("Data e-mailed", "",
  339. `Your data has been e-mailed to you as an attachment.`))
  340. }
  341. // handleWipeSubscriberData allows a subscriber to delete their data. The
  342. // profile and subscriptions are deleted, while the campaign_views and link
  343. // clicks remain as orphan data unconnected to any subscriber.
  344. func handleWipeSubscriberData(c echo.Context) error {
  345. var (
  346. app = c.Get("app").(*App)
  347. subUUID = c.Param("subUUID")
  348. )
  349. // Is wiping allowed?
  350. if !app.constants.Privacy.AllowExport {
  351. return c.Render(http.StatusBadRequest, tplMessage,
  352. makeMsgTpl("Invalid request", "",
  353. "The feature is not available."))
  354. }
  355. if _, err := app.queries.DeleteSubscribers.Exec(nil, pq.StringArray{subUUID}); err != nil {
  356. app.log.Printf("error wiping subscriber data: %s", err)
  357. return c.Render(http.StatusInternalServerError, tplMessage,
  358. makeMsgTpl("Error processing request", "",
  359. "There was an error processing your request. Please try later."))
  360. }
  361. return c.Render(http.StatusOK, tplMessage,
  362. makeMsgTpl("Data removed", "",
  363. `Your subscriptions and all associated data has been removed.`))
  364. }
  365. // drawTransparentImage draws a transparent PNG of given dimensions
  366. // and returns the PNG bytes.
  367. func drawTransparentImage(h, w int) []byte {
  368. var (
  369. img = image.NewRGBA(image.Rect(0, 0, w, h))
  370. out = &bytes.Buffer{}
  371. )
  372. _ = png.Encode(out, img)
  373. return out.Bytes()
  374. }