A clean, Markdown-based publishing platform made for writers. Write together, and build a community. https://writefreely.org
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 

469 linhas
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.Email.Domain, app.cfg.Email.MailgunPrivate)
  273. m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(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. if title != "" {
  287. title = string(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
  288. }
  289. m.AddTag("New post")
  290. fontFam := "Lora, Palatino, Baskerville, serif"
  291. if p.IsSans() {
  292. fontFam = `"Open Sans", Tahoma, Arial, sans-serif`
  293. } else if p.IsMonospace() {
  294. fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace`
  295. }
  296. // TODO: move this to a templated file and LESS-generated stylesheet
  297. fullHTML := `<html>
  298. <head>
  299. <style>
  300. body {
  301. font-size: 120%;
  302. font-family: ` + fontFam + `;
  303. margin: 1em 2em;
  304. }
  305. #article {
  306. line-height: 1.5;
  307. margin: 1.5em 0;
  308. white-space: pre-wrap;
  309. word-wrap: break-word;
  310. }
  311. h1, h2, h3, h4, h5, h6, p, code {
  312. display: inline
  313. }
  314. img, iframe, video {
  315. max-width: 100%
  316. }
  317. #title {
  318. margin-bottom: 1em;
  319. display: block;
  320. }
  321. .intro {
  322. font-style: italic;
  323. font-size: 0.95em;
  324. }
  325. div#footer {
  326. text-align: center;
  327. max-width: 35em;
  328. margin: 2em auto;
  329. }
  330. div#footer p {
  331. display: block;
  332. font-size: 0.86em;
  333. color: #666;
  334. }
  335. hr {
  336. border: 1px solid #ccc;
  337. margin: 2em 1em;
  338. }
  339. p#emailsub {
  340. text-align: center;
  341. display: inline-block !important;
  342. width: 100%;
  343. font-style: italic;
  344. }
  345. </style>
  346. </head>
  347. <body>
  348. <div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
  349. ` + string(p.HTMLContent) + `</div>
  350. <hr />
  351. <div id="footer">
  352. <p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
  353. <p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
  354. </div>
  355. </body>
  356. </html>`
  357. // inline CSS
  358. html, err := inliner.Inline(fullHTML)
  359. if err != nil {
  360. log.Error("Unable to inline email HTML: %v", err)
  361. return err
  362. }
  363. m.SetHtml(html)
  364. log.Info("[email] Adding %d recipient(s)", len(subs))
  365. for _, s := range subs {
  366. e := s.FinalEmail(app.keys)
  367. log.Info("[email] Adding %s", e)
  368. err = m.AddRecipientAndVariables(e, map[string]interface{}{
  369. "id": s.ID,
  370. "to": e,
  371. "token": s.Token,
  372. })
  373. if err != nil {
  374. log.Error("Unable to add receipient %s: %s", e, err)
  375. }
  376. }
  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.Email.Domain, app.cfg.Email.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.Email.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. }