A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

463 lines
13 KiB

  1. /*
  2. * Copyright © 2019-2021 Musing Studio LLC.
  3. *
  4. * This file is part of WriteFreely.
  5. *
  6. * WriteFreely is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License, included
  8. * in the LICENSE file in this source code package.
  9. */
  10. package writefreely
  11. import (
  12. "database/sql"
  13. "encoding/json"
  14. "fmt"
  15. "html/template"
  16. "net/http"
  17. "strings"
  18. "time"
  19. "github.com/aymerick/douceur/inliner"
  20. "github.com/gorilla/mux"
  21. "github.com/mailgun/mailgun-go"
  22. stripmd "github.com/writeas/go-strip-markdown/v2"
  23. "github.com/writeas/impart"
  24. "github.com/writeas/web-core/data"
  25. "github.com/writeas/web-core/log"
  26. "github.com/writefreely/writefreely/key"
  27. "github.com/writefreely/writefreely/spam"
  28. )
  29. const (
  30. emailSendDelay = 15
  31. )
  32. type (
  33. SubmittedSubscription struct {
  34. CollAlias string
  35. UserID int64
  36. Email string `schema:"email" json:"email"`
  37. Web bool `schema:"web" json:"web"`
  38. Slug string `schema:"slug" json:"slug"`
  39. From string `schema:"from" json:"from"`
  40. }
  41. EmailSubscriber struct {
  42. ID string
  43. CollID int64
  44. UserID sql.NullInt64
  45. Email sql.NullString
  46. Subscribed time.Time
  47. Token string
  48. Confirmed bool
  49. AllowExport bool
  50. acctEmail sql.NullString
  51. }
  52. )
  53. func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string {
  54. if !es.UserID.Valid || es.Email.Valid {
  55. return es.Email.String
  56. }
  57. decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String))
  58. if err != nil {
  59. log.Error("Error decrypting user email: %v", err)
  60. return ""
  61. }
  62. return string(decEmail)
  63. }
  64. func (es *EmailSubscriber) SubscribedFriendly() string {
  65. return es.Subscribed.Format("January 2, 2006")
  66. }
  67. func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
  68. reqJSON := IsJSON(r)
  69. vars := mux.Vars(r)
  70. var err error
  71. ss := SubmittedSubscription{
  72. CollAlias: vars["alias"],
  73. }
  74. u := getUserSession(app, r)
  75. if u != nil {
  76. ss.UserID = u.ID
  77. }
  78. if reqJSON {
  79. // Decode JSON request
  80. decoder := json.NewDecoder(r.Body)
  81. err = decoder.Decode(&ss)
  82. if err != nil {
  83. log.Error("Couldn't parse new subscription JSON request: %v\n", err)
  84. return ErrBadJSON
  85. }
  86. } else {
  87. err = r.ParseForm()
  88. if err != nil {
  89. log.Error("Couldn't parse new subscription form request: %v\n", err)
  90. return ErrBadFormData
  91. }
  92. err = app.formDecoder.Decode(&ss, r.PostForm)
  93. if err != nil {
  94. log.Error("Continuing, but error decoding new subscription form request: %v\n", err)
  95. //return ErrBadFormData
  96. }
  97. }
  98. c, err := app.db.GetCollection(ss.CollAlias)
  99. if err != nil {
  100. log.Error("getCollection: %s", err)
  101. return err
  102. }
  103. c.hostName = app.cfg.App.Host
  104. from := c.CanonicalURL()
  105. isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID)
  106. if isAuthorBanned {
  107. log.Info("Author is silenced, so subscription is blocked.")
  108. return impart.HTTPError{http.StatusFound, from}
  109. }
  110. if ss.Web {
  111. if u != nil && u.ID == c.OwnerID {
  112. from = "/" + c.Alias + "/"
  113. }
  114. from += ss.Slug
  115. }
  116. if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" {
  117. log.Info("Honeypot field was filled out! Not subscribing.")
  118. return impart.HTTPError{http.StatusFound, from}
  119. }
  120. if ss.Email == "" && ss.UserID < 1 {
  121. log.Info("No subscriber data. Not subscribing.")
  122. return impart.HTTPError{http.StatusFound, from}
  123. }
  124. confirmed := app.db.IsSubscriberConfirmed(ss.Email)
  125. es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
  126. if err != nil {
  127. log.Error("addEmailSubscription: %s", err)
  128. return err
  129. }
  130. // Send confirmation email if needed
  131. if !confirmed {
  132. err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
  133. if err != nil {
  134. log.Error("Failed to send subscription confirmation email: %s", err)
  135. return err
  136. }
  137. }
  138. if ss.Web {
  139. session, err := app.sessionStore.Get(r, userEmailCookieName)
  140. if err != nil {
  141. // The cookie should still save, even if there's an error.
  142. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
  143. log.Error("Getting user email cookie: %v; ignoring", err)
  144. }
  145. if confirmed {
  146. addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
  147. } else {
  148. addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
  149. }
  150. session.Values[userEmailCookieVal] = ss.Email
  151. err = session.Save(r, w)
  152. if err != nil {
  153. log.Error("save email cookie: %s", err)
  154. return err
  155. }
  156. return impart.HTTPError{http.StatusFound, from}
  157. }
  158. return impart.WriteSuccess(w, "", http.StatusAccepted)
  159. }
  160. func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
  161. alias := collectionAliasFromReq(r)
  162. vars := mux.Vars(r)
  163. subID := vars["subscriber"]
  164. email := r.FormValue("email")
  165. token := r.FormValue("t")
  166. slug := r.FormValue("slug")
  167. isWeb := r.Method == "GET"
  168. // Display collection if this is a collection
  169. var c *Collection
  170. var err error
  171. if app.cfg.App.SingleUser {
  172. c, err = app.db.GetCollectionByID(1)
  173. } else {
  174. c, err = app.db.GetCollection(alias)
  175. }
  176. if err != nil {
  177. log.Error("Get collection: %s", err)
  178. return err
  179. }
  180. from := c.CanonicalURL()
  181. if subID != "" {
  182. // User unsubscribing via email, so assume action is taken by either current
  183. // user or not current user, and only use the request's information to
  184. // satisfy this unsubscribe, i.e. subscriberID and token.
  185. err = app.db.DeleteEmailSubscriber(subID, token)
  186. } else {
  187. // User unsubscribing through the web app, so assume action is taken by
  188. // currently-auth'd user.
  189. var userID int64
  190. u := getUserSession(app, r)
  191. if u != nil {
  192. // User is logged in
  193. userID = u.ID
  194. if userID == c.OwnerID {
  195. from = "/" + c.Alias + "/"
  196. }
  197. }
  198. if email == "" && userID <= 0 {
  199. // Get email address from saved cookie
  200. session, err := app.sessionStore.Get(r, userEmailCookieName)
  201. if err != nil {
  202. log.Error("Unable to get email cookie: %s", err)
  203. } else {
  204. email = session.Values[userEmailCookieVal].(string)
  205. }
  206. }
  207. if email == "" && userID <= 0 {
  208. err = fmt.Errorf("No subscriber given.")
  209. log.Error("Not deleting subscription: %s", err)
  210. return err
  211. }
  212. err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
  213. }
  214. if err != nil {
  215. log.Error("Unable to delete subscriber: %v", err)
  216. return err
  217. }
  218. if isWeb {
  219. from += slug
  220. addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
  221. return impart.HTTPError{http.StatusFound, from}
  222. }
  223. return impart.WriteSuccess(w, "", http.StatusAccepted)
  224. }
  225. func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
  226. alias := collectionAliasFromReq(r)
  227. subID := mux.Vars(r)["subscriber"]
  228. token := r.FormValue("t")
  229. var c *Collection
  230. var err error
  231. if app.cfg.App.SingleUser {
  232. c, err = app.db.GetCollectionByID(1)
  233. } else {
  234. c, err = app.db.GetCollection(alias)
  235. }
  236. if err != nil {
  237. log.Error("Get collection: %s", err)
  238. return err
  239. }
  240. from := c.CanonicalURL()
  241. err = app.db.UpdateSubscriberConfirmed(subID, token)
  242. if err != nil {
  243. addSessionFlash(app, w, r, err.Error(), nil)
  244. return impart.HTTPError{http.StatusFound, from}
  245. }
  246. addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
  247. return impart.HTTPError{http.StatusFound, from}
  248. }
  249. func emailPost(app *App, p *PublicPost, collID int64) error {
  250. p.augmentContent()
  251. // Do some shortcode replacement.
  252. // Since the user is receiving this email, we can assume they're subscribed via email.
  253. p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1)
  254. if p.HTMLContent == template.HTML("") {
  255. p.formatContent(app.cfg, false, false)
  256. }
  257. p.augmentReadingDestination()
  258. title := p.Title.String
  259. if title != "" {
  260. title = p.Title.String + "\n\n"
  261. }
  262. plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content)
  263. plainMsg += `
  264. ---------------------------------------------------------------------------------
  265. Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to.
  266. Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
  267. gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
  268. m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(p.DisplayTitle()), plainMsg)
  269. replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
  270. if replyTo != "" {
  271. m.SetReplyTo(replyTo)
  272. }
  273. subs, err := app.db.GetEmailSubscribers(collID, true)
  274. if err != nil {
  275. log.Error("Unable to get email subscribers: %v", err)
  276. return err
  277. }
  278. if len(subs) == 0 {
  279. return nil
  280. }
  281. if title != "" {
  282. title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
  283. }
  284. m.AddTag("New post")
  285. fontFam := "Lora, Palatino, Baskerville, serif"
  286. if p.IsSans() {
  287. fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
  288. } else if p.IsMonospace() {
  289. fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
  290. }
  291. // TODO: move this to a templated file and LESS-generated stylesheet
  292. fullHTML := `<html>
  293. <head>
  294. <style>
  295. body {
  296. font-size: 120%;
  297. font-family: ` + fontFam + `;
  298. margin: 1em 2em;
  299. }
  300. #article {
  301. line-height: 1.5;
  302. margin: 1.5em 0;
  303. white-space: pre-wrap;
  304. word-wrap: break-word;
  305. }
  306. h1, h2, h3, h4, h5, h6, p, code {
  307. display: inline
  308. }
  309. img, iframe, video {
  310. max-width: 100%
  311. }
  312. #title {
  313. margin-bottom: 1em;
  314. display: block;
  315. }
  316. .intro {
  317. font-style: italic;
  318. font-size: 0.95em;
  319. }
  320. div#footer {
  321. text-align: center;
  322. max-width: 35em;
  323. margin: 2em auto;
  324. }
  325. div#footer p {
  326. display: block;
  327. font-size: 0.86em;
  328. color: #666;
  329. }
  330. hr {
  331. border: 1px solid #ccc;
  332. margin: 2em 1em;
  333. }
  334. p#emailsub {
  335. text-align: center;
  336. display: inline-block !important;
  337. width: 100%;
  338. font-style: italic;
  339. }
  340. </style>
  341. </head>
  342. <body>
  343. <div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
  344. ` + string(p.HTMLContent) + `</div>
  345. <hr />
  346. <div id="footer">
  347. <p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
  348. <p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
  349. </div>
  350. </body>
  351. </html>`
  352. // inline CSS
  353. html, err := inliner.Inline(fullHTML)
  354. if err != nil {
  355. log.Error("Unable to inline email HTML: %v", err)
  356. return err
  357. }
  358. m.SetHtml(html)
  359. log.Info("[email] Adding %d recipient(s)", len(subs))
  360. for _, s := range subs {
  361. e := s.FinalEmail(app.keys)
  362. log.Info("[email] Adding %s", e)
  363. err = m.AddRecipientAndVariables(e, map[string]interface{}{
  364. "id": s.ID,
  365. "to": e,
  366. "token": s.Token,
  367. })
  368. if err != nil {
  369. log.Error("Unable to add receipient %s: %s", e, err)
  370. }
  371. }
  372. res, _, err := gun.Send(m)
  373. log.Info("[email] Send result: %s", res)
  374. if err != nil {
  375. log.Error("Unable to send post email: %v", err)
  376. return err
  377. }
  378. return nil
  379. }
  380. func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
  381. if email == "" {
  382. return fmt.Errorf("You must supply an email to verify.")
  383. }
  384. // Send email
  385. gun := mailgun.NewMailgun(app.cfg.Email.Domain, app.cfg.Email.MailgunPrivate)
  386. plainMsg := "Confirm your subscription to " + c.DisplayTitle() + ` (` + c.CanonicalURL() + `) to start receiving future posts. Simply click the following link (or copy and paste it into your browser):
  387. ` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
  388. If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.`
  389. m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Email.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
  390. m.AddTag("Email Verification")
  391. m.SetHtml(`<html>
  392. <body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
  393. <div style="font-size: 1.2em;">
  394. <p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
  395. <p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
  396. <p>If you didn't subscribe to this site or you're not sure why you're getting this email, you can delete it. You won't be subscribed or receive any future emails.</p>
  397. </div>
  398. </body>
  399. </html>`)
  400. gun.Send(m)
  401. return nil
  402. }