public.go 10 KB

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