A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 
 
 

467 řádky
13 KiB

  1. /*
  2. * Copyright © 2019-2021 A Bunch Tell 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. // Do email validation
  125. // TODO: move this to an AJAX call before submitting email address, so we can immediately show errors to user
  126. /*
  127. err := validate(ss.Email)
  128. if err != nil {
  129. addSessionFlash(w, r, err.Error(), nil)
  130. return impart.HTTPError{http.StatusFound, from}
  131. }
  132. */
  133. confirmed := app.db.IsSubscriberConfirmed(ss.Email)
  134. es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed)
  135. if err != nil {
  136. log.Error("addEmailSubscription: %s", err)
  137. return err
  138. }
  139. // Send confirmation email if needed
  140. if !confirmed {
  141. sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
  142. }
  143. if ss.Web {
  144. session, err := app.sessionStore.Get(r, userEmailCookieName)
  145. if err != nil {
  146. // The cookie should still save, even if there's an error.
  147. // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144
  148. log.Error("Getting user email cookie: %v; ignoring", err)
  149. }
  150. if confirmed {
  151. addSessionFlash(app, w, r, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
  152. } else {
  153. addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> to subscribe.", nil)
  154. }
  155. session.Values[userEmailCookieVal] = ss.Email
  156. err = session.Save(r, w)
  157. if err != nil {
  158. log.Error("save email cookie: %s", err)
  159. return err
  160. }
  161. return impart.HTTPError{http.StatusFound, from}
  162. }
  163. return impart.WriteSuccess(w, "", http.StatusAccepted)
  164. }
  165. func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
  166. alias := collectionAliasFromReq(r)
  167. vars := mux.Vars(r)
  168. subID := vars["subscriber"]
  169. email := r.FormValue("email")
  170. token := r.FormValue("t")
  171. slug := r.FormValue("slug")
  172. isWeb := r.Method == "GET"
  173. // Display collection if this is a collection
  174. var c *Collection
  175. var err error
  176. if app.cfg.App.SingleUser {
  177. c, err = app.db.GetCollectionByID(1)
  178. } else {
  179. c, err = app.db.GetCollection(alias)
  180. }
  181. if err != nil {
  182. log.Error("Get collection: %s", err)
  183. return err
  184. }
  185. from := c.CanonicalURL()
  186. if subID != "" {
  187. // User unsubscribing via email, so assume action is taken by either current
  188. // user or not current user, and only use the request's information to
  189. // satisfy this unsubscribe, i.e. subscriberID and token.
  190. err = app.db.DeleteEmailSubscriber(subID, token)
  191. } else {
  192. // User unsubscribing through the web app, so assume action is taken by
  193. // currently-auth'd user.
  194. var userID int64
  195. u := getUserSession(app, r)
  196. if u != nil {
  197. // User is logged in
  198. userID = u.ID
  199. if userID == c.OwnerID {
  200. from = "/" + c.Alias + "/"
  201. }
  202. }
  203. if email == "" && userID <= 0 {
  204. // Get email address from saved cookie
  205. session, err := app.sessionStore.Get(r, userEmailCookieName)
  206. if err != nil {
  207. log.Error("Unable to get email cookie: %s", err)
  208. } else {
  209. email = session.Values[userEmailCookieVal].(string)
  210. }
  211. }
  212. if email == "" && userID <= 0 {
  213. err = fmt.Errorf("No subscriber given.")
  214. log.Error("Not deleting subscription: %s", err)
  215. return err
  216. }
  217. err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID)
  218. }
  219. if err != nil {
  220. log.Error("Unable to delete subscriber: %v", err)
  221. return err
  222. }
  223. if isWeb {
  224. from += slug
  225. addSessionFlash(app, w, r, "<strong>Unsubscribed</strong>. You will no longer receive these blog posts via email.", nil)
  226. return impart.HTTPError{http.StatusFound, from}
  227. }
  228. return impart.WriteSuccess(w, "", http.StatusAccepted)
  229. }
  230. func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error {
  231. alias := collectionAliasFromReq(r)
  232. subID := mux.Vars(r)["subscriber"]
  233. token := r.FormValue("t")
  234. var c *Collection
  235. var err error
  236. if app.cfg.App.SingleUser {
  237. c, err = app.db.GetCollectionByID(1)
  238. } else {
  239. c, err = app.db.GetCollection(alias)
  240. }
  241. if err != nil {
  242. log.Error("Get collection: %s", err)
  243. return err
  244. }
  245. from := c.CanonicalURL()
  246. err = app.db.UpdateSubscriberConfirmed(subID, token)
  247. if err != nil {
  248. addSessionFlash(app, w, r, err.Error(), nil)
  249. return impart.HTTPError{http.StatusFound, from}
  250. }
  251. addSessionFlash(app, w, r, "<strong>Confirmed</strong>! Thanks. Now you'll receive future blog posts via email.", nil)
  252. return impart.HTTPError{http.StatusFound, from}
  253. }
  254. func emailPost(app *App, p *PublicPost, collID int64) error {
  255. p.augmentContent()
  256. // Do some shortcode replacement.
  257. // Since the user is receiving this email, we can assume they're subscribed via email.
  258. p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -1)
  259. if p.HTMLContent == template.HTML("") {
  260. p.formatContent(app.cfg, false, false)
  261. }
  262. p.augmentReadingDestination()
  263. title := p.Title.String
  264. if title != "" {
  265. title = p.Title.String + "\n\n"
  266. }
  267. plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content)
  268. plainMsg += `
  269. ---------------------------------------------------------------------------------
  270. Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to.
  271. Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%`
  272. gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate)
  273. m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Letters.Domain+">", p.Collection.DisplayTitle()+": "+p.DisplayTitle(), plainMsg)
  274. replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo)
  275. if replyTo != "" {
  276. m.SetReplyTo(replyTo)
  277. }
  278. subs, err := app.db.GetEmailSubscribers(collID, true)
  279. if err != nil {
  280. log.Error("Unable to get email subscribers: %v", err)
  281. return err
  282. }
  283. if len(subs) == 0 {
  284. return nil
  285. }
  286. log.Info("[email] Adding %d recipient(s)", len(subs))
  287. for _, s := range subs {
  288. e := s.FinalEmail(app.keys)
  289. log.Info("[email] Adding %s", e)
  290. err = m.AddRecipientAndVariables(e, map[string]interface{}{
  291. "id": s.ID,
  292. "to": e,
  293. "token": s.Token,
  294. })
  295. if err != nil {
  296. log.Error("Unable to add receipient %s: %s", e, err)
  297. }
  298. }
  299. if title != "" {
  300. title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
  301. }
  302. m.AddTag("New post")
  303. fontFam := "Lora, Palatino, Baskerville, serif"
  304. if p.IsSans() {
  305. fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
  306. } else if p.IsMonospace() {
  307. fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
  308. }
  309. // TODO: move this to a templated file and LESS-generated stylesheet
  310. fullHTML := `<html>
  311. <head>
  312. <style>
  313. body {
  314. font-size: 120%;
  315. font-family: ` + fontFam + `;
  316. margin: 1em 2em;
  317. }
  318. #article {
  319. line-height: 1.5;
  320. margin: 1.5em 0;
  321. white-space: pre-wrap;
  322. word-wrap: break-word;
  323. }
  324. h1, h2, h3, h4, h5, h6, p, code {
  325. display: inline
  326. }
  327. img, iframe, video {
  328. max-width: 100%
  329. }
  330. #title {
  331. margin-bottom: 1em;
  332. display: block;
  333. }
  334. .intro {
  335. font-style: italic;
  336. font-size: 0.95em;
  337. }
  338. div#footer {
  339. text-align: center;
  340. max-width: 35em;
  341. margin: 2em auto;
  342. }
  343. div#footer p {
  344. display: block;
  345. font-size: 0.86em;
  346. color: #666;
  347. }
  348. hr {
  349. border: 1px solid #ccc;
  350. margin: 2em 1em;
  351. }
  352. p#emailsub {
  353. text-align: center;
  354. display: inline-block !important;
  355. width: 100%;
  356. font-style: italic;
  357. }
  358. </style>
  359. </head>
  360. <body>
  361. <div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
  362. ` + string(p.HTMLContent) + `</div>
  363. <hr />
  364. <div id="footer">
  365. <p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
  366. <p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
  367. </div>
  368. </body>
  369. </html>`
  370. // inline CSS
  371. html, err := inliner.Inline(fullHTML)
  372. if err != nil {
  373. log.Error("Unable to inline email HTML: %v", err)
  374. return err
  375. }
  376. m.SetHtml(html)
  377. res, _, err := gun.Send(m)
  378. log.Info("[email] Send result: %s", res)
  379. if err != nil {
  380. log.Error("Unable to send post email: %v", err)
  381. return err
  382. }
  383. return nil
  384. }
  385. func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error {
  386. if email == "" {
  387. return fmt.Errorf("You must supply an email to verify.")
  388. }
  389. // Send email
  390. gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate)
  391. 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):
  392. ` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + `
  393. 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.`
  394. m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Letters.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email))
  395. m.AddTag("Email Verification")
  396. m.SetHtml(`<html>
  397. <body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
  398. <div style="font-size: 1.2em;">
  399. <p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
  400. <p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
  401. <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>
  402. </div>
  403. </body>
  404. </html>`)
  405. gun.Send(m)
  406. return nil
  407. }