diff --git a/account.go b/account.go index 72d12ee..0a6c4ae 100644 --- a/account.go +++ b/account.go @@ -869,12 +869,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques *UserPage *Collection Silenced bool + + config.LettersCfg + LetterReplyTo string }{ UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes), Collection: c, Silenced: silenced, + LettersCfg: app.cfg.Letters, } obj.UserPage.CollAlias = c.Alias + if obj.LettersCfg.Enabled() { + obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo) + } showUserPage(w, "collection", obj) return nil diff --git a/app.go b/app.go index 40eb858..9a72dc9 100644 --- a/app.go +++ b/app.go @@ -415,6 +415,17 @@ func Initialize(apper Apper, debug bool) (*App, error) { initActivityPub(apper.App()) + if apper.App().cfg.Letters.Domain != "" || apper.App().cfg.Letters.MailgunPrivate != "" { + if apper.App().cfg.Letters.Domain == "" { + log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.") + } else if apper.App().cfg.Letters.MailgunPrivate == "" { + log.Error("[FAILED] Starting publish jobs queue: no [letters]mailgun_private config value set.") + } else { + log.Info("Starting publish jobs queue...") + go startPublishJobsQueue(apper.App()) + } + } + // Handle local timeline, if enabled if apper.App().cfg.App.LocalTimeline { log.Info("Initializing local timeline...") diff --git a/collections.go b/collections.go index f79cc2d..4f1cfad 100644 --- a/collections.go +++ b/collections.go @@ -33,9 +33,12 @@ import ( "github.com/writefreely/writefreely/author" "github.com/writefreely/writefreely/config" "github.com/writefreely/writefreely/page" + "github.com/writefreely/writefreely/spam" "golang.org/x/net/idna" ) +const collAttrLetterReplyTo = "letter_reply_to" + type ( // TODO: add Direction to db // TODO: add Language to db @@ -87,6 +90,7 @@ type ( Privacy int `schema:"privacy" json:"privacy"` Pass string `schema:"password" json:"password"` MathJax bool `schema:"mathjax" json:"mathjax"` + EmailSubs bool `schema:"email_subs" json:"email_subs"` Handle string `schema:"handle" json:"handle"` // Actual collection values updated in the DB @@ -97,6 +101,7 @@ type ( Script *sql.NullString `schema:"script" json:"script"` Signature *sql.NullString `schema:"signature" json:"signature"` Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"` + LetterReply *string `schema:"letter_reply" json:"letter_reply"` Visibility *int `schema:"visibility" json:"public"` Format *sql.NullString `schema:"format" json:"format"` } @@ -353,6 +358,10 @@ func (c *Collection) RenderMathJax() bool { return c.db.CollectionHasAttribute(c.ID, "render_mathjax") } +func (c *Collection) EmailSubsEnabled() bool { + return c.db.CollectionHasAttribute(c.ID, "email_subs") +} + func (c *Collection) MonetizationURL() string { if c.Monetization == "" { return "" @@ -575,13 +584,17 @@ type CollectionPage struct { IsWelcome bool IsOwner bool IsCollLoggedIn bool + Honeypot string + IsSubscriber bool CanPin bool Username string Monetization string + Flash template.HTML Collections *[]Collection PinnedPosts *[]PublicPost - IsAdmin bool - CanInvite bool + + IsAdmin bool + CanInvite bool // Helper field for Chorus mode CollAlias string @@ -821,14 +834,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro StaticPage: pageForReq(app, r), IsCustomDomain: cr.isCustomDomain, IsWelcome: r.FormValue("greeting") != "", + Honeypot: spam.HoneypotFieldName(), CollAlias: c.Alias, } + flashes, _ := getSessionFlashes(app, w, r, nil) + for _, f := range flashes { + displayPage.Flash = template.HTML(f) + } displayPage.IsAdmin = u != nil && u.IsAdmin() displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin) var owner *User if u != nil { displayPage.Username = u.Username displayPage.IsOwner = u.ID == coll.OwnerID + displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID) if displayPage.IsOwner { // Add in needed information for users viewing their own collection owner = u diff --git a/config/config.go b/config/config.go index 0f07329..00dd6a5 100644 --- a/config/config.go +++ b/config/config.go @@ -170,11 +170,17 @@ type ( DisablePasswordAuth bool `ini:"disable_password_auth"` } + LettersCfg struct { + Domain string `ini:"domain"` + MailgunPrivate string `ini:"mailgun_private"` + } + // Config holds the complete configuration for running a writefreely instance Config struct { Server ServerCfg `ini:"server"` Database DatabaseCfg `ini:"database"` App AppCfg `ini:"app"` + Letters LettersCfg `ini:"letters"` SlackOauth SlackOauthCfg `ini:"oauth.slack"` WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"` GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"` @@ -235,6 +241,10 @@ func (ac *AppCfg) LandingPath() string { return ac.Landing } +func (lc LettersCfg) Enabled() bool { + return lc.Domain != "" && lc.MailgunPrivate != "" +} + func (ac AppCfg) SignupPath() string { if !ac.OpenRegistration { return "" diff --git a/database.go b/database.go index fefc3c1..2232652 100644 --- a/database.go +++ b/database.go @@ -14,6 +14,7 @@ import ( "context" "database/sql" "fmt" + "github.com/go-sql-driver/mysql" "github.com/writeas/web-core/silobridge" wf_db "github.com/writefreely/writefreely/db" "net/http" @@ -932,6 +933,41 @@ func (db *datastore) UpdateCollection(c *SubmittedCollection, alias string) erro } } + // Update EmailSub value + if c.EmailSubs { + // TODO: ensure these work with SQLite + _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, "email_subs", "1", "1") + if err != nil { + log.Error("Unable to insert email_subs value: %v", err) + return err + } + skipUpdate := false + if c.LetterReply != nil { + // Strip away any excess spaces + trimmed := strings.TrimSpace(*c.LetterReply) + // Only update value when it contains "@" + if strings.IndexRune(trimmed, '@') > 0 { + c.LetterReply = &trimmed + } else { + // Value appears invalid, so don't update + skipUpdate = true + } + if !skipUpdate { + _, err = db.Exec("INSERT INTO collectionattributes (collection_id, attribute, value) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE value = ?", collID, collAttrLetterReplyTo, *c.LetterReply, *c.LetterReply) + if err != nil { + log.Error("Unable to insert %s value: %v", collAttrLetterReplyTo, err) + return err + } + } + } + } else { + _, err = db.Exec("DELETE FROM collectionattributes WHERE collection_id = ? AND attribute = ?", collID, "email_subs") + if err != nil { + log.Error("Unable to delete email_subs value: %v", err) + return err + } + } + // Update rest of the collection data if q.Updates != "" { res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...) @@ -2812,3 +2848,243 @@ func (db *datastore) GetProfilePageFromHandle(app *App, handle string) (string, } return actorIRI, nil } + +func (db *datastore) AddEmailSubscription(collID, userID int64, email string, confirmed bool) (*EmailSubscriber, error) { + friendlyChars := "0123456789BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz" + subID := id.GenerateRandomString(friendlyChars, 8) + token := id.GenerateRandomString(friendlyChars, 16) + emailVal := sql.NullString{ + String: email, + Valid: email != "", + } + userIDVal := sql.NullInt64{ + Int64: userID, + Valid: userID > 0, + } + + _, err := db.Exec("INSERT INTO emailsubscribers (id, collection_id, user_id, email, subscribed, token, confirmed) VALUES (?, ?, ?, ?, NOW(), ?, ?)", subID, collID, userIDVal, emailVal, token, confirmed) + if err != nil { + if mysqlErr, ok := err.(*mysql.MySQLError); ok { + if mysqlErr.Number == mySQLErrDuplicateKey { + // Duplicate, so just return existing subscriber information + log.Info("Duplicate subscriber for email %s, user %d; returning existing subscriber", email, userID) + return db.FetchEmailSubscriber(email, userID, collID) + } + } + return nil, err + } + + return &EmailSubscriber{ + ID: subID, + CollID: collID, + UserID: userIDVal, + Email: emailVal, + Token: token, + }, nil +} + +func (db *datastore) IsEmailSubscriber(email string, userID, collID int64) bool { + var dummy int + var err error + if email != "" { + err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID).Scan(&dummy) + } else { + err = db.QueryRow("SELECT 1 FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID).Scan(&dummy) + } + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + return false + } + return true +} + +func (db *datastore) GetEmailSubscribers(collID int64, reqConfirmed bool) ([]*EmailSubscriber, error) { + cond := "" + if reqConfirmed { + cond = " AND confirmed = 1" + } + rows, err := db.Query(`SELECT s.id, collection_id, user_id, s.email, u.email, subscribed, token, confirmed, allow_export +FROM emailsubscribers s +LEFT JOIN users u + ON u.id = user_id +WHERE collection_id = ?`+cond+` +ORDER BY subscribed DESC`, collID) + if err != nil { + log.Error("Failed selecting email subscribers for collection %d: %v", collID, err) + return nil, err + } + defer rows.Close() + + var subs []*EmailSubscriber + for rows.Next() { + s := &EmailSubscriber{} + err = rows.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.acctEmail, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) + if err != nil { + log.Error("Failed scanning row from email subscribers: %v", err) + continue + } + subs = append(subs, s) + } + return subs, nil +} + +func (db *datastore) FetchEmailSubscriberEmail(subID, token string) (string, error) { + var email sql.NullString + // TODO: return user email if there's a user_id ? + err := db.QueryRow("SELECT email FROM emailsubscribers WHERE id = ? AND token = ?", subID, token).Scan(&email) + switch { + case err == sql.ErrNoRows: + return "", fmt.Errorf("Subscriber doesn't exist or token is invalid.") + case err != nil: + log.Error("Couldn't SELECT email from emailsubscribers: %v", err) + return "", fmt.Errorf("Something went very wrong.") + } + + return email.String, nil +} + +func (db *datastore) FetchEmailSubscriber(email string, userID, collID int64) (*EmailSubscriber, error) { + const emailSubCols = "id, collection_id, user_id, email, subscribed, token, confirmed, allow_export" + + s := &EmailSubscriber{} + var row *sql.Row + if email != "" { + row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) + } else { + row = db.QueryRow("SELECT "+emailSubCols+" FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) + } + err := row.Scan(&s.ID, &s.CollID, &s.UserID, &s.Email, &s.Subscribed, &s.Token, &s.Confirmed, &s.AllowExport) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + return nil, err + } + return s, nil +} + +func (db *datastore) DeleteEmailSubscriber(subID, token string) error { + res, err := db.Exec("DELETE FROM emailsubscribers WHERE id = ? AND token = ?", subID, token) + if err != nil { + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + return impart.HTTPError{http.StatusNotFound, "Invalid token, or subscriber doesn't exist"} + } + return nil +} + +func (db *datastore) DeleteEmailSubscriberByUser(email string, userID, collID int64) error { + var res sql.Result + var err error + if email != "" { + res, err = db.Exec("DELETE FROM emailsubscribers WHERE email = ? AND collection_id = ?", email, collID) + } else { + res, err = db.Exec("DELETE FROM emailsubscribers WHERE user_id = ? AND collection_id = ?", userID, collID) + } + if err != nil { + return err + } + + rowsAffected, _ := res.RowsAffected() + if rowsAffected == 0 { + return impart.HTTPError{http.StatusNotFound, "Subscriber doesn't exist"} + } + return nil +} + +func (db *datastore) UpdateSubscriberConfirmed(subID, token string) error { + email, err := db.FetchEmailSubscriberEmail(subID, token) + if err != nil { + log.Error("Didn't fetch email subscriber: %v", err) + return err + } + + // TODO: ensure all addresses with original name are also confirmed, e.g. matt+fake@write.as and matt@write.as are now confirmed + _, err = db.Exec("UPDATE emailsubscribers SET confirmed = 1 WHERE email = ?", email) + if err != nil { + log.Error("Could not update email subscriber confirmation status: %v", err) + return err + } + return nil +} + +func (db *datastore) IsSubscriberConfirmed(email string) bool { + var dummy int64 + err := db.QueryRow("SELECT 1 FROM emailsubscribers WHERE email = ? AND confirmed = 1", email).Scan(&dummy) + switch { + case err == sql.ErrNoRows: + return false + case err != nil: + log.Error("Couldn't SELECT in isSubscriberConfirmed: %v", err) + return false + } + + return true +} + +func (db *datastore) InsertJob(j *PostJob) error { + res, err := db.Exec("INSERT INTO publishjobs (post_id, action, delay) VALUES (?, ?, ?)", j.PostID, j.Action, j.Delay) + if err != nil { + return err + } + jobID, err := res.LastInsertId() + if err != nil { + log.Error("[jobs] Couldn't get last insert ID! %s", err) + } + log.Info("[jobs] Queued %s job #%d for post %s, delayed %d minutes", j.Action, jobID, j.PostID, j.Delay) + return nil +} + +func (db *datastore) UpdateJobForPost(postID string, delay int64) error { + _, err := db.Exec("UPDATE publishjobs SET delay = ? WHERE post_id = ?", delay, postID) + if err != nil { + return fmt.Errorf("Unable to update publish job: %s", err) + } + log.Info("Updated job for post %s: delay %d", postID, delay) + return nil +} + +func (db *datastore) DeleteJob(id int64) error { + _, err := db.Exec("DELETE FROM publishjobs WHERE id = ?", id) + if err != nil { + return err + } + log.Info("[job #%d] Deleted.", id) + return nil +} + +func (db *datastore) DeleteJobByPost(postID string) error { + _, err := db.Exec("DELETE FROM publishjobs WHERE post_id = ?", postID) + if err != nil { + return err + } + log.Info("[job] Deleted job for post %s", postID) + return nil +} + +func (db *datastore) GetJobsToRun(action string) ([]*PostJob, error) { + rows, err := db.Query(`SELECT pj.id, post_id, action, delay + FROM publishjobs pj + INNER JOIN posts p + ON post_id = p.id + WHERE action = ? AND created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE) + ORDER BY created ASC`, action) + if err != nil { + log.Error("Failed selecting from publishjobs: %v", err) + return nil, impart.HTTPError{http.StatusInternalServerError, "Couldn't retrieve publish jobs."} + } + defer rows.Close() + + jobs := []*PostJob{} + for rows.Next() { + j := &PostJob{} + err = rows.Scan(&j.ID, &j.PostID, &j.Action, &j.Delay) + jobs = append(jobs, j) + } + return jobs, nil +} diff --git a/email.go b/email.go new file mode 100644 index 0000000..451f988 --- /dev/null +++ b/email.go @@ -0,0 +1,466 @@ +/* + * Copyright © 2019-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package writefreely + +import ( + "database/sql" + "encoding/json" + "fmt" + "html/template" + "net/http" + "strings" + "time" + + "github.com/aymerick/douceur/inliner" + "github.com/gorilla/mux" + "github.com/mailgun/mailgun-go" + stripmd "github.com/writeas/go-strip-markdown/v2" + "github.com/writeas/impart" + "github.com/writeas/web-core/data" + "github.com/writeas/web-core/log" + "github.com/writefreely/writefreely/key" + "github.com/writefreely/writefreely/spam" +) + +const ( + emailSendDelay = 15 +) + +type ( + SubmittedSubscription struct { + CollAlias string + UserID int64 + + Email string `schema:"email" json:"email"` + Web bool `schema:"web" json:"web"` + Slug string `schema:"slug" json:"slug"` + From string `schema:"from" json:"from"` + } + + EmailSubscriber struct { + ID string + CollID int64 + UserID sql.NullInt64 + Email sql.NullString + Subscribed time.Time + Token string + Confirmed bool + AllowExport bool + acctEmail sql.NullString + } +) + +func (es *EmailSubscriber) FinalEmail(keys *key.Keychain) string { + if !es.UserID.Valid || es.Email.Valid { + return es.Email.String + } + + decEmail, err := data.Decrypt(keys.EmailKey, []byte(es.acctEmail.String)) + if err != nil { + log.Error("Error decrypting user email: %v", err) + return "" + } + return string(decEmail) +} + +func (es *EmailSubscriber) SubscribedFriendly() string { + return es.Subscribed.Format("January 2, 2006") +} + +func handleCreateEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + reqJSON := IsJSON(r) + vars := mux.Vars(r) + var err error + + ss := SubmittedSubscription{ + CollAlias: vars["alias"], + } + u := getUserSession(app, r) + if u != nil { + ss.UserID = u.ID + } + if reqJSON { + // Decode JSON request + decoder := json.NewDecoder(r.Body) + err = decoder.Decode(&ss) + if err != nil { + log.Error("Couldn't parse new subscription JSON request: %v\n", err) + return ErrBadJSON + } + } else { + err = r.ParseForm() + if err != nil { + log.Error("Couldn't parse new subscription form request: %v\n", err) + return ErrBadFormData + } + + err = app.formDecoder.Decode(&ss, r.PostForm) + if err != nil { + log.Error("Continuing, but error decoding new subscription form request: %v\n", err) + //return ErrBadFormData + } + } + + c, err := app.db.GetCollection(ss.CollAlias) + if err != nil { + log.Error("getCollection: %s", err) + return err + } + c.hostName = app.cfg.App.Host + + from := c.CanonicalURL() + isAuthorBanned, err := app.db.IsUserSilenced(c.OwnerID) + if isAuthorBanned { + log.Info("Author is silenced, so subscription is blocked.") + return impart.HTTPError{http.StatusFound, from} + } + + if ss.Web { + if u != nil && u.ID == c.OwnerID { + from = "/" + c.Alias + "/" + } + from += ss.Slug + } + + if r.FormValue(spam.HoneypotFieldName()) != "" || r.FormValue("fake_password") != "" { + log.Info("Honeypot field was filled out! Not subscribing.") + return impart.HTTPError{http.StatusFound, from} + } + + if ss.Email == "" && ss.UserID < 1 { + log.Info("No subscriber data. Not subscribing.") + return impart.HTTPError{http.StatusFound, from} + } + + // Do email validation + // TODO: move this to an AJAX call before submitting email address, so we can immediately show errors to user + /* + err := validate(ss.Email) + if err != nil { + addSessionFlash(w, r, err.Error(), nil) + return impart.HTTPError{http.StatusFound, from} + } + */ + + confirmed := app.db.IsSubscriberConfirmed(ss.Email) + es, err := app.db.AddEmailSubscription(c.ID, ss.UserID, ss.Email, confirmed) + if err != nil { + log.Error("addEmailSubscription: %s", err) + return err + } + + // Send confirmation email if needed + if !confirmed { + sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token) + } + + if ss.Web { + session, err := app.sessionStore.Get(r, userEmailCookieName) + if err != nil { + // The cookie should still save, even if there's an error. + // Source: https://github.com/gorilla/sessions/issues/16#issuecomment-143642144 + log.Error("Getting user email cookie: %v; ignoring", err) + } + if confirmed { + addSessionFlash(app, w, r, "Subscribed. You'll now receive future blog posts via email.", nil) + } else { + addSessionFlash(app, w, r, "Please check your email and click the confirmation link to subscribe.", nil) + } + session.Values[userEmailCookieVal] = ss.Email + err = session.Save(r, w) + if err != nil { + log.Error("save email cookie: %s", err) + return err + } + + return impart.HTTPError{http.StatusFound, from} + } + return impart.WriteSuccess(w, "", http.StatusAccepted) +} + +func handleDeleteEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + + vars := mux.Vars(r) + subID := vars["subscriber"] + email := r.FormValue("email") + token := r.FormValue("t") + slug := r.FormValue("slug") + isWeb := r.Method == "GET" + + // Display collection if this is a collection + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + log.Error("Get collection: %s", err) + return err + } + + from := c.CanonicalURL() + + if subID != "" { + // User unsubscribing via email, so assume action is taken by either current + // user or not current user, and only use the request's information to + // satisfy this unsubscribe, i.e. subscriberID and token. + err = app.db.DeleteEmailSubscriber(subID, token) + } else { + // User unsubscribing through the web app, so assume action is taken by + // currently-auth'd user. + var userID int64 + u := getUserSession(app, r) + if u != nil { + // User is logged in + userID = u.ID + if userID == c.OwnerID { + from = "/" + c.Alias + "/" + } + } + if email == "" && userID <= 0 { + // Get email address from saved cookie + session, err := app.sessionStore.Get(r, userEmailCookieName) + if err != nil { + log.Error("Unable to get email cookie: %s", err) + } else { + email = session.Values[userEmailCookieVal].(string) + } + } + + if email == "" && userID <= 0 { + err = fmt.Errorf("No subscriber given.") + log.Error("Not deleting subscription: %s", err) + return err + } + + err = app.db.DeleteEmailSubscriberByUser(email, userID, c.ID) + } + if err != nil { + log.Error("Unable to delete subscriber: %v", err) + return err + } + + if isWeb { + from += slug + addSessionFlash(app, w, r, "Unsubscribed. You will no longer receive these blog posts via email.", nil) + return impart.HTTPError{http.StatusFound, from} + } + return impart.WriteSuccess(w, "", http.StatusAccepted) +} + +func handleConfirmEmailSubscription(app *App, w http.ResponseWriter, r *http.Request) error { + alias := collectionAliasFromReq(r) + subID := mux.Vars(r)["subscriber"] + token := r.FormValue("t") + + var c *Collection + var err error + if app.cfg.App.SingleUser { + c, err = app.db.GetCollectionByID(1) + } else { + c, err = app.db.GetCollection(alias) + } + if err != nil { + log.Error("Get collection: %s", err) + return err + } + + from := c.CanonicalURL() + + err = app.db.UpdateSubscriberConfirmed(subID, token) + if err != nil { + addSessionFlash(app, w, r, err.Error(), nil) + return impart.HTTPError{http.StatusFound, from} + } + + addSessionFlash(app, w, r, "Confirmed! Thanks. Now you'll receive future blog posts via email.", nil) + return impart.HTTPError{http.StatusFound, from} +} + +func emailPost(app *App, p *PublicPost, collID int64) error { + p.augmentContent() + + // Do some shortcode replacement. + // Since the user is receiving this email, we can assume they're subscribed via email. + p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates.

`, -1) + + if p.HTMLContent == template.HTML("") { + p.formatContent(app.cfg, false, false) + } + p.augmentReadingDestination() + + title := p.Title.String + if title != "" { + title = p.Title.String + "\n\n" + } + plainMsg := title + "A new post from " + p.CanonicalURL(app.cfg.App.Host) + "\n\n" + stripmd.Strip(p.Content) + plainMsg += ` + +--------------------------------------------------------------------------------- + +Originally published on ` + p.Collection.DisplayTitle() + ` (` + p.Collection.CanonicalURL() + `), a blog you subscribe to. + +Sent to %recipient.to%. Unsubscribe: ` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%` + + gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) + m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Letters.Domain+">", p.Collection.DisplayTitle()+": "+p.DisplayTitle(), plainMsg) + replyTo := app.db.GetCollectionAttribute(collID, collAttrLetterReplyTo) + if replyTo != "" { + m.SetReplyTo(replyTo) + } + + subs, err := app.db.GetEmailSubscribers(collID, true) + if err != nil { + log.Error("Unable to get email subscribers: %v", err) + return err + } + if len(subs) == 0 { + return nil + } + log.Info("[email] Adding %d recipient(s)", len(subs)) + for _, s := range subs { + e := s.FinalEmail(app.keys) + log.Info("[email] Adding %s", e) + err = m.AddRecipientAndVariables(e, map[string]interface{}{ + "id": s.ID, + "to": e, + "token": s.Token, + }) + if err != nil { + log.Error("Unable to add receipient %s: %s", e, err) + } + } + + if title != "" { + title = string(`

` + p.FormattedDisplayTitle() + `

`) + } + m.AddTag("New post") + + fontFam := "Lora, Palatino, Baskerville, serif" + if p.IsSans() { + fontFam = `"Open Sans", Tahoma, Arial, sans-serif` + } else if p.IsMonospace() { + fontFam = `Hack, consolas, Menlo-Regular, Menlo, Monaco, monospace, monospace` + } + + // TODO: move this to a templated file and LESS-generated stylesheet + fullHTML := ` + + + + +
` + title + `

From ` + p.DisplayCanonicalURL() + `

+ +` + string(p.HTMLContent) + `
+
+ + +` + + // inline CSS + html, err := inliner.Inline(fullHTML) + if err != nil { + log.Error("Unable to inline email HTML: %v", err) + return err + } + + m.SetHtml(html) + res, _, err := gun.Send(m) + log.Info("[email] Send result: %s", res) + if err != nil { + log.Error("Unable to send post email: %v", err) + return err + } + + return nil +} + +func sendSubConfirmEmail(app *App, c *Collection, email, subID, token string) error { + if email == "" { + return fmt.Errorf("You must supply an email to verify.") + } + + // Send email + gun := mailgun.NewMailgun(app.cfg.Letters.Domain, app.cfg.Letters.MailgunPrivate) + + 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): + +` + c.CanonicalURL() + "email/confirm/" + subID + "?t=" + token + ` + +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.` + m := mailgun.NewMessage(c.DisplayTitle()+" <"+c.Alias+"@"+app.cfg.Letters.Domain+">", "Confirm your subscription to "+c.DisplayTitle(), plainMsg, fmt.Sprintf("<%s>", email)) + m.AddTag("Email Verification") + + m.SetHtml(` + +
+

Confirm your subscription to ` + c.DisplayTitle() + ` to start receiving future posts:

+

Subscribe to ` + c.DisplayTitle() + `

+

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.

+
+ +`) + gun.Send(m) + + return nil +} diff --git a/go.mod b/go.mod index 8344d4c..9a6616b 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,17 @@ module github.com/writefreely/writefreely require ( + github.com/PuerkitoBio/goquery v1.7.0 // indirect + github.com/aymerick/douceur v0.2.0 github.com/clbanning/mxj v1.8.4 // indirect github.com/dustin/go-humanize v1.0.0 + github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c // indirect + github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect + github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 // indirect github.com/fatih/color v1.10.0 github.com/go-sql-driver/mysql v1.6.0 github.com/go-test/deep v1.0.1 // indirect + github.com/gobuffalo/envy v1.9.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect github.com/gorilla/csrf v1.7.0 github.com/gorilla/feeds v1.1.1 @@ -18,11 +24,14 @@ require ( github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/lunixbochs/vtclean v1.0.0 // indirect + github.com/mailgun/mailgun-go v2.0.0+incompatible github.com/manifoldco/promptui v0.8.0 github.com/mattn/go-sqlite3 v1.14.6 github.com/microcosm-cc/bluemonday v1.0.5 github.com/mitchellh/go-wordwrap v1.0.1 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d + github.com/onsi/ginkgo v1.16.4 // indirect + github.com/onsi/gomega v1.13.0 // indirect github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect @@ -32,6 +41,7 @@ require ( github.com/writeas/activity v0.1.2 github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 github.com/writeas/go-strip-markdown v2.0.1+incompatible + github.com/writeas/go-strip-markdown/v2 v2.1.1 github.com/writeas/go-webfinger v1.1.0 github.com/writeas/httpsig v1.0.0 github.com/writeas/impart v1.1.1 @@ -42,7 +52,7 @@ require ( github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f github.com/writefreely/go-nodeinfo v1.2.0 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 - golang.org/x/net v0.0.0-20200707034311-ab3426394381 + golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 gopkg.in/ini.v1 v1.62.0 ) diff --git a/go.sum b/go.sum index 354a1dd..007047b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,10 @@ code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs= code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PuerkitoBio/goquery v1.7.0 h1:O5SP3b9JWqMSVMG69zMfj577zwkSNpxrFf7ybS74eiw= +github.com/PuerkitoBio/goquery v1.7.0/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= @@ -27,21 +31,48 @@ github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0 github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c h1:8ISkoahWXwZR41ois5lSJBSVw4D0OV19Ht/JSTzvSv0= +github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 h1:JWuenKqqX8nojtoVVWjGfOF9635RETekkoH6Cc9SX0A= +github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg= +github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4 h1:7HZCaLC5+BZpmbhCOZJ293Lz68O7PYrF2EzeiFMwCLk= +github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0= github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg= github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8= github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg= github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= +github.com/gobuffalo/envy v1.9.0 h1:eZR0DuEgVLfeIb1zIKt3bT4YovIMf9O9LXQeCZLXpqE= +github.com/gobuffalo/envy v1.9.0/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w= github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84= github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y= @@ -66,8 +97,11 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM= github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw= +github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= +github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= @@ -82,6 +116,8 @@ github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1: github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mailgun/mailgun-go v2.0.0+incompatible h1:0FoRHWwMUctnd8KIR3vtZbqdfjpIMxOZgcSa51s8F8o= +github.com/mailgun/mailgun-go v2.0.0+incompatible/go.mod h1:NWTyU+O4aczg/nsGhQnvHL6v2n5Gy6Sv5tNDVvC6FbU= github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= @@ -99,8 +135,18 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= -github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak= +github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -109,6 +155,8 @@ github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469 h1:rAbv2gekFbUc github.com/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469/go.mod h1:c61IFFAJw8ADWu54tti30Tj5VrBstVoTprmET35UEkY= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ= github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q= +github.com/rogpeppe/go-internal v1.3.2 h1:XU784Pr0wdahMY2bYcyK6N1KuaRAdLtqD4qd8D18Bfs= +github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= @@ -118,6 +166,8 @@ github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304/go.mod h1 github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -129,6 +179,8 @@ github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481 h1:BiSivIxLQ github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481/go.mod h1:4akDJSl+sSp+QhrQKMqzAqdV1gJ1pPx6XPI77zgMM8o= github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw= github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE= +github.com/writeas/go-strip-markdown/v2 v2.1.1 h1:hAxUM21Uhznf/FnbVGiJciqzska6iLei22Ijc3q2e28= +github.com/writeas/go-strip-markdown/v2 v2.1.1/go.mod h1:UvvgPJgn1vvN8nWuE5e7v/+qmDu3BSVnKAB6Gl7hFzA= github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ= @@ -155,33 +207,84 @@ github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f h1:ItBZYzdIbBmm github.com/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f/go.mod h1:DzNxa0YLV/wNeeWeHFPNa/nHmyJBFIIzXN/m9PpDm5c= github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190131182504-b8fe1690c613/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20200707034311-ab3426394381 h1:VXak5I6aEWmAXeQjA+QSZzlgNrpq9mjcfDemuexIKsU= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jobs.go b/jobs.go new file mode 100644 index 0000000..251b82d --- /dev/null +++ b/jobs.go @@ -0,0 +1,72 @@ +package writefreely + +import ( + "github.com/writeas/web-core/log" + "time" +) + +type PostJob struct { + ID int64 + PostID string + Action string + Delay int64 +} + +func addJob(app *App, p *PublicPost, action string, delay int64) error { + j := &PostJob{ + PostID: p.ID, + Action: action, + Delay: delay, + } + return app.db.InsertJob(j) +} + +func startPublishJobsQueue(app *App) { + t := time.NewTicker(62 * time.Second) + for { + log.Info("[jobs] Done.") + <-t.C + log.Info("[jobs] Fetching email publish jobs...") + jobs, err := app.db.GetJobsToRun("email") + if err != nil { + log.Error("[jobs] %s - Skipping.", err) + continue + } + log.Info("[jobs] Running %d email publish jobs...", len(jobs)) + err = runJobs(app, jobs, true) + if err != nil { + log.Error("[jobs] Failed: %s", err) + } + } +} + +func runJobs(app *App, jobs []*PostJob, reqColl bool) error { + for _, j := range jobs { + p, err := app.db.GetPost(j.PostID, 0) + if err != nil { + log.Info("[job #%d] Unable to get post: %s", j.ID, err) + continue + } + if !p.CollectionID.Valid && reqColl { + log.Info("[job #%d] Post %s not part of a collection", j.ID, p.ID) + app.db.DeleteJob(j.ID) + continue + } + coll, err := app.db.GetCollectionByID(p.CollectionID.Int64) + if err != nil { + log.Info("[job #%d] Unable to get collection: %s", j.ID, err) + continue + } + coll.hostName = app.cfg.App.Host + coll.ForPublic() + p.Collection = &CollectionObj{Collection: *coll} + err = emailPost(app, p, p.Collection.ID) + if err != nil { + log.Error("[job #%d] Failed to email post %s", j.ID, p.ID) + continue + } + log.Info("[job #%d] Success for post %s.", j.ID, p.ID) + app.db.DeleteJob(j.ID) + } + return nil +} diff --git a/less/core.less b/less/core.less index 75a801b..30ecaf1 100644 --- a/less/core.less +++ b/less/core.less @@ -210,6 +210,10 @@ body { pre { line-height: 1.5; } + .flash { + text-align: center; + margin-bottom: 4em; + } } &#subpage { #wrapper { @@ -1596,6 +1600,18 @@ pre.code-block { overflow-x: auto; } +#emailsub { + text-align: center; +} +p#emailsub { + display: inline-block !important; + width: 100%; + font-style: italic; +} +#subscribe-btn { + margin-left: 0.5em; +} + #org-nav { font-family: @sansFont; font-size: 1.1em; diff --git a/migrations/drivers.go b/migrations/drivers.go index 1399411..b4a95bd 100644 --- a/migrations/drivers.go +++ b/migrations/drivers.go @@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string { return "SMALLINT" } +func (db *datastore) typeTinyInt() string { + if db.driverName == driverSQLite { + return "INTEGER" + } + return "TINYINT" +} + func (db *datastore) typeText() string { return "TEXT" } diff --git a/migrations/migrations.go b/migrations/migrations.go index b3ebcc0..4fe7162 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -65,7 +65,8 @@ var migrations = []Migration{ New("support oauth attach", oauthAttach), // V6 -> V7 New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0) New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9 - New("support post signatures", supportPostSignatures), // V9 -> V10 + New("support post signatures", supportPostSignatures), // V9 -> V10 (v0.13.0) + New("support newsletters", supportLetters), // V10 -> V11 } // CurrentVer returns the current migration version the application is on diff --git a/migrations/v11.go b/migrations/v11.go new file mode 100644 index 0000000..cb509f2 --- /dev/null +++ b/migrations/v11.go @@ -0,0 +1,60 @@ +/* + * Copyright © 2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package migrations + +func supportLetters(db *datastore) error { + t, err := db.Begin() + if err != nil { + t.Rollback() + return err + } + + _, err = t.Exec(`CREATE TABLE publishjobs ( + id ` + db.typeInt() + ` auto_increment, + post_id ` + db.typeVarChar(16) + ` not null, + action ` + db.typeVarChar(16) + ` not null, + delay ` + db.typeTinyInt() + ` not null, + PRIMARY KEY (id) +)`) + if err != nil { + t.Rollback() + return err + } + + // TODO: fix for SQLite database + _, err = t.Exec(`CREATE TABLE emailsubscribers ( + id char(8) not null, + collection_id int not null, + user_id int null, + email varchar(255) null, + subscribed datetime not null, + token char(16) not null, + confirmed tinyint(1) default 0 not null, + allow_export tinyint(1) default 0 not null, + constraint eu_coll_email + unique (collection_id, email), + constraint eu_coll_user + unique (collection_id, user_id), + PRIMARY KEY (id) +)`) + if err != nil { + t.Rollback() + return err + } + + err = t.Commit() + if err != nil { + t.Rollback() + return err + } + + return nil +} diff --git a/posts.go b/posts.go index 4d8d019..9da0975 100644 --- a/posts.go +++ b/posts.go @@ -14,6 +14,7 @@ import ( "database/sql" "encoding/json" "fmt" + "github.com/writefreely/writefreely/spam" "html/template" "net/http" "net/url" @@ -651,8 +652,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error { // Write success now response := impart.WriteSuccess(w, newPost, http.StatusCreated) - if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { - go federatePost(app, newPost, newPost.Collection.ID, false) + if newPost.Collection != nil { + if !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) { + go federatePost(app, newPost, newPost.Collection.ID, false) + } + if app.cfg.Letters.Enabled() && newPost.Collection.EmailSubsEnabled() { + go app.db.InsertJob(&PostJob{ + PostID: newPost.ID, + Action: "email", + Delay: emailSendDelay, + }) + } } return response @@ -952,16 +962,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error { return err } - if !app.cfg.App.Private && app.cfg.App.Federation { - for _, pRes := range *res { - if pRes.Code != http.StatusOK { - continue - } + for _, pRes := range *res { + if pRes.Code != http.StatusOK { + continue + } + if !app.cfg.App.Private && app.cfg.App.Federation { if !pRes.Post.Created.After(time.Now()) { pRes.Post.Collection.hostName = app.cfg.App.Host go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false) } } + if app.cfg.Letters.Enabled() && pRes.Post.Collection.EmailSubsEnabled() { + go app.db.InsertJob(&PostJob{ + PostID: pRes.Post.ID, + Action: "email", + Delay: emailSendDelay, + }) + } } return impart.WriteSuccess(w, res, http.StatusOK) } @@ -1164,6 +1181,15 @@ func (p *PublicPost) CanonicalURL(hostName string) string { return p.Collection.CanonicalURL() + p.Slug.String } +func (pp *PublicPost) DisplayCanonicalURL() string { + us := pp.CanonicalURL(pp.Collection.hostName) + u, err := url.Parse(us) + if err != nil { + return us + } + return u.Hostname() + u.Path +} + func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object { cfg := app.cfg var o *activitystreams.Object @@ -1530,6 +1556,15 @@ Are you sure it was ever here?`, } else { p.extractData() p.Content = strings.Replace(p.Content, "", "", 1) + if app.cfg.Letters.Enabled() && c.EmailSubsEnabled() { + // TODO: indicate plan is inactive or subs disabled when OWNER is viewing their own post. + if u != nil && u.IsEmailSubscriber(app, c.ID) { + p.Content = strings.Replace(p.Content, "", `

You're subscribed to email updates. Unsubscribe.

`, -1) + } else { + p.Content = strings.Replace(p.Content, "", `
`, -1) + } + } + p.Content = strings.Replace(p.Content, "<!--emailsub-->", "", 1) // TODO: move this to function p.formatContent(app.cfg, cr.isCollOwner, true) tp := CollectionPostPage{ @@ -1593,6 +1628,14 @@ func (p *Post) extractData() { p.extractImages() } +func (p *Post) IsSans() bool { + return p.Font == "sans" +} + +func (p *Post) IsMonospace() bool { + return p.Font == "mono" +} + func (rp *RawPost) UserFacingCreated() string { return rp.Created.Format(postMetaDateFormat) } diff --git a/routes.go b/routes.go index 213958d..e1b6261 100644 --- a/routes.go +++ b/routes.go @@ -147,6 +147,9 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router { apiColls.HandleFunc("/{alias}/collect", handler.All(addPost)).Methods("POST") apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST") apiColls.HandleFunc("/{alias}/unpin", handler.All(pinPost)).Methods("POST") + apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleCreateEmailSubscription)).Methods("POST") + apiColls.HandleFunc("/{alias}/email/subscribe", handler.All(handleDeleteEmailSubscription)).Methods("DELETE") + apiColls.HandleFunc("/{collection}/email/unsubscribe", handler.All(handleDeleteEmailSubscription)).Methods("GET") apiColls.HandleFunc("/{alias}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST") apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET") apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).Methods("GET") @@ -220,6 +223,8 @@ func RouteCollections(handler *Handler, r *mux.Router) { r.HandleFunc("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader)) r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap)) r.HandleFunc("/feed/", handler.AllReader(ViewFeed)) + r.HandleFunc("/email/confirm/{subscriber}", handler.All(handleConfirmEmailSubscription)).Methods("GET") + r.HandleFunc("/email/unsubscribe/{subscriber}", handler.All(handleDeleteEmailSubscription)).Methods("GET") r.HandleFunc("/{slug}", handler.CollectionPostOrStatic) r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser)) r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser)) diff --git a/session.go b/session.go index 81d628f..100842f 100644 --- a/session.go +++ b/session.go @@ -21,6 +21,10 @@ import ( const ( day = 86400 sessionLength = 180 * day + + userEmailCookieName = "ue" + userEmailCookieVal = "email" + cookieName = "wfu" cookieUserVal = "u" diff --git a/spam/email.go b/spam/email.go new file mode 100644 index 0000000..76158f5 --- /dev/null +++ b/spam/email.go @@ -0,0 +1,43 @@ +/* + * Copyright © 2020-2021 A Bunch Tell LLC. + * + * This file is part of WriteFreely. + * + * WriteFreely is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, included + * in the LICENSE file in this source code package. + */ + +package spam + +import ( + "github.com/writeas/web-core/id" + "strings" +) + +var honeypotField string + +func HoneypotFieldName() string { + if honeypotField == "" { + honeypotField = id.Generate62RandomString(39) + } + return honeypotField +} + +// CleanEmail takes an email address and strips it down to a unique address that can be blocked. +func CleanEmail(email string) string { + emailParts := strings.Split(strings.ToLower(email), "@") + if len(emailParts) < 2 { + return "" + } + u := emailParts[0] + d := emailParts[1] + // Ignore anything after '+' + plusIdx := strings.IndexRune(u, '+') + if plusIdx > -1 { + u = u[:plusIdx] + } + // Strip dots in email address + u = strings.ReplaceAll(u, ".", "") + return u + "@" + d +} diff --git a/templates/collection.tmpl b/templates/collection.tmpl index 493e6b7..7669c88 100644 --- a/templates/collection.tmpl +++ b/templates/collection.tmpl @@ -102,6 +102,12 @@ {{end}} + {{if .Flash}} +
+

{{.Flash}}

+
+ {{end}} + {{template "posts" .}} {{if gt .TotalPages 1}}{{end}} + {{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}} + {{if .Posts}}{{else}}{{end}} {{if .ShowFooterBranding }} diff --git a/templates/include/post-render.tmpl b/templates/include/post-render.tmpl index 5b84845..fd3cbf2 100644 --- a/templates/include/post-render.tmpl +++ b/templates/include/post-render.tmpl @@ -102,3 +102,28 @@ {{end}} + +{{define "emailsubscribe"}} + {{if .EmailSubsEnabled}} +
+ {{if .IsSubscriber}} +

You're subscribed to email updates. Unsubscribe.

+ {{else}} +
+ +

Enter your email to subscribe to updates.

+ + +
+ + {{end}} +
+ {{end}} +{{end}} \ No newline at end of file diff --git a/templates/user/collection.tmpl b/templates/user/collection.tmpl index 041c107..d8cbb36 100644 --- a/templates/user/collection.tmpl +++ b/templates/user/collection.tmpl @@ -94,6 +94,44 @@ textarea.section.norm { +
+

Updates

+
+

Keep readers updated with your latest posts wherever they are.

+ +
+
+

Display Format

@@ -249,6 +287,13 @@ var $customDomain = document.getElementById('domain-alias'); var $customHandleEnv = document.getElementById('custom-handle-env'); var $normalHandleEnv = document.getElementById('normal-handle-env'); +var $emailSubsCheck = document.getElementById('email_subs'); +var $letterReply = document.getElementById('letter_reply'); +H.getEl('email_subs').on('click', function() { + let show = $emailSubsCheck.checked + $letterReply.disabled = !show +}) + if (matchMedia('(pointer:fine)').matches) { // Only initialize Ace editor on devices with a mouse var opt = { diff --git a/users.go b/users.go index cc6764f..1e18e24 100644 --- a/users.go +++ b/users.go @@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool { func (u *User) IsSilenced() bool { return u.Status&UserSilenced != 0 } + +func (u *User) IsEmailSubscriber(app *App, collID int64) bool { + return app.db.IsEmailSubscriber("", u.ID, collID) +}