mirror of
https://github.com/writefreely/writefreely
synced 2024-11-10 03:24:11 +00:00
Support email subscriptions (base)
This adds beginning email subscription functionality, with only MySQL support, Mailgun support, and incomplete support for private instances. It includes database changes, so run: writefreely db migrate to use this feature. Ref T856
This commit is contained in:
parent
e983c4527f
commit
2ea235f0c4
21 changed files with 1250 additions and 15 deletions
|
@ -869,12 +869,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
|
||||||
*UserPage
|
*UserPage
|
||||||
*Collection
|
*Collection
|
||||||
Silenced bool
|
Silenced bool
|
||||||
|
|
||||||
|
config.LettersCfg
|
||||||
|
LetterReplyTo string
|
||||||
}{
|
}{
|
||||||
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
UserPage: NewUserPage(app, r, u, "Edit "+c.DisplayTitle(), flashes),
|
||||||
Collection: c,
|
Collection: c,
|
||||||
Silenced: silenced,
|
Silenced: silenced,
|
||||||
|
LettersCfg: app.cfg.Letters,
|
||||||
}
|
}
|
||||||
obj.UserPage.CollAlias = c.Alias
|
obj.UserPage.CollAlias = c.Alias
|
||||||
|
if obj.LettersCfg.Enabled() {
|
||||||
|
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
|
||||||
|
}
|
||||||
|
|
||||||
showUserPage(w, "collection", obj)
|
showUserPage(w, "collection", obj)
|
||||||
return nil
|
return nil
|
||||||
|
|
11
app.go
11
app.go
|
@ -415,6 +415,17 @@ func Initialize(apper Apper, debug bool) (*App, error) {
|
||||||
|
|
||||||
initActivityPub(apper.App())
|
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
|
// Handle local timeline, if enabled
|
||||||
if apper.App().cfg.App.LocalTimeline {
|
if apper.App().cfg.App.LocalTimeline {
|
||||||
log.Info("Initializing local timeline...")
|
log.Info("Initializing local timeline...")
|
||||||
|
|
|
@ -33,9 +33,12 @@ import (
|
||||||
"github.com/writefreely/writefreely/author"
|
"github.com/writefreely/writefreely/author"
|
||||||
"github.com/writefreely/writefreely/config"
|
"github.com/writefreely/writefreely/config"
|
||||||
"github.com/writefreely/writefreely/page"
|
"github.com/writefreely/writefreely/page"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"golang.org/x/net/idna"
|
"golang.org/x/net/idna"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const collAttrLetterReplyTo = "letter_reply_to"
|
||||||
|
|
||||||
type (
|
type (
|
||||||
// TODO: add Direction to db
|
// TODO: add Direction to db
|
||||||
// TODO: add Language to db
|
// TODO: add Language to db
|
||||||
|
@ -87,6 +90,7 @@ type (
|
||||||
Privacy int `schema:"privacy" json:"privacy"`
|
Privacy int `schema:"privacy" json:"privacy"`
|
||||||
Pass string `schema:"password" json:"password"`
|
Pass string `schema:"password" json:"password"`
|
||||||
MathJax bool `schema:"mathjax" json:"mathjax"`
|
MathJax bool `schema:"mathjax" json:"mathjax"`
|
||||||
|
EmailSubs bool `schema:"email_subs" json:"email_subs"`
|
||||||
Handle string `schema:"handle" json:"handle"`
|
Handle string `schema:"handle" json:"handle"`
|
||||||
|
|
||||||
// Actual collection values updated in the DB
|
// Actual collection values updated in the DB
|
||||||
|
@ -97,6 +101,7 @@ type (
|
||||||
Script *sql.NullString `schema:"script" json:"script"`
|
Script *sql.NullString `schema:"script" json:"script"`
|
||||||
Signature *sql.NullString `schema:"signature" json:"signature"`
|
Signature *sql.NullString `schema:"signature" json:"signature"`
|
||||||
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
Monetization *string `schema:"monetization_pointer" json:"monetization_pointer"`
|
||||||
|
LetterReply *string `schema:"letter_reply" json:"letter_reply"`
|
||||||
Visibility *int `schema:"visibility" json:"public"`
|
Visibility *int `schema:"visibility" json:"public"`
|
||||||
Format *sql.NullString `schema:"format" json:"format"`
|
Format *sql.NullString `schema:"format" json:"format"`
|
||||||
}
|
}
|
||||||
|
@ -353,6 +358,10 @@ func (c *Collection) RenderMathJax() bool {
|
||||||
return c.db.CollectionHasAttribute(c.ID, "render_mathjax")
|
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 {
|
func (c *Collection) MonetizationURL() string {
|
||||||
if c.Monetization == "" {
|
if c.Monetization == "" {
|
||||||
return ""
|
return ""
|
||||||
|
@ -575,13 +584,17 @@ type CollectionPage struct {
|
||||||
IsWelcome bool
|
IsWelcome bool
|
||||||
IsOwner bool
|
IsOwner bool
|
||||||
IsCollLoggedIn bool
|
IsCollLoggedIn bool
|
||||||
|
Honeypot string
|
||||||
|
IsSubscriber bool
|
||||||
CanPin bool
|
CanPin bool
|
||||||
Username string
|
Username string
|
||||||
Monetization string
|
Monetization string
|
||||||
|
Flash template.HTML
|
||||||
Collections *[]Collection
|
Collections *[]Collection
|
||||||
PinnedPosts *[]PublicPost
|
PinnedPosts *[]PublicPost
|
||||||
IsAdmin bool
|
|
||||||
CanInvite bool
|
IsAdmin bool
|
||||||
|
CanInvite bool
|
||||||
|
|
||||||
// Helper field for Chorus mode
|
// Helper field for Chorus mode
|
||||||
CollAlias string
|
CollAlias string
|
||||||
|
@ -821,14 +834,20 @@ func handleViewCollection(app *App, w http.ResponseWriter, r *http.Request) erro
|
||||||
StaticPage: pageForReq(app, r),
|
StaticPage: pageForReq(app, r),
|
||||||
IsCustomDomain: cr.isCustomDomain,
|
IsCustomDomain: cr.isCustomDomain,
|
||||||
IsWelcome: r.FormValue("greeting") != "",
|
IsWelcome: r.FormValue("greeting") != "",
|
||||||
|
Honeypot: spam.HoneypotFieldName(),
|
||||||
CollAlias: c.Alias,
|
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.IsAdmin = u != nil && u.IsAdmin()
|
||||||
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
displayPage.CanInvite = canUserInvite(app.cfg, displayPage.IsAdmin)
|
||||||
var owner *User
|
var owner *User
|
||||||
if u != nil {
|
if u != nil {
|
||||||
displayPage.Username = u.Username
|
displayPage.Username = u.Username
|
||||||
displayPage.IsOwner = u.ID == coll.OwnerID
|
displayPage.IsOwner = u.ID == coll.OwnerID
|
||||||
|
displayPage.IsSubscriber = u.IsEmailSubscriber(app, coll.ID)
|
||||||
if displayPage.IsOwner {
|
if displayPage.IsOwner {
|
||||||
// Add in needed information for users viewing their own collection
|
// Add in needed information for users viewing their own collection
|
||||||
owner = u
|
owner = u
|
||||||
|
|
|
@ -170,11 +170,17 @@ type (
|
||||||
DisablePasswordAuth bool `ini:"disable_password_auth"`
|
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 holds the complete configuration for running a writefreely instance
|
||||||
Config struct {
|
Config struct {
|
||||||
Server ServerCfg `ini:"server"`
|
Server ServerCfg `ini:"server"`
|
||||||
Database DatabaseCfg `ini:"database"`
|
Database DatabaseCfg `ini:"database"`
|
||||||
App AppCfg `ini:"app"`
|
App AppCfg `ini:"app"`
|
||||||
|
Letters LettersCfg `ini:"letters"`
|
||||||
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
SlackOauth SlackOauthCfg `ini:"oauth.slack"`
|
||||||
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
WriteAsOauth WriteAsOauthCfg `ini:"oauth.writeas"`
|
||||||
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
GitlabOauth GitlabOauthCfg `ini:"oauth.gitlab"`
|
||||||
|
@ -235,6 +241,10 @@ func (ac *AppCfg) LandingPath() string {
|
||||||
return ac.Landing
|
return ac.Landing
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (lc LettersCfg) Enabled() bool {
|
||||||
|
return lc.Domain != "" && lc.MailgunPrivate != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (ac AppCfg) SignupPath() string {
|
func (ac AppCfg) SignupPath() string {
|
||||||
if !ac.OpenRegistration {
|
if !ac.OpenRegistration {
|
||||||
return ""
|
return ""
|
||||||
|
|
276
database.go
276
database.go
|
@ -14,6 +14,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/writeas/web-core/silobridge"
|
"github.com/writeas/web-core/silobridge"
|
||||||
wf_db "github.com/writefreely/writefreely/db"
|
wf_db "github.com/writefreely/writefreely/db"
|
||||||
"net/http"
|
"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
|
// Update rest of the collection data
|
||||||
if q.Updates != "" {
|
if q.Updates != "" {
|
||||||
res, err = db.Exec("UPDATE collections SET "+q.Updates+" WHERE "+q.Conditions, q.Params...)
|
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
|
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
|
||||||
|
}
|
||||||
|
|
466
email.go
Normal file
466
email.go
Normal file
|
@ -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, "<strong>Subscribed</strong>. You'll now receive future blog posts via email.", nil)
|
||||||
|
} else {
|
||||||
|
addSessionFlash(app, w, r, "Please check your email and <strong>click the confirmation link</strong> 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, "<strong>Unsubscribed</strong>. 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, "<strong>Confirmed</strong>! 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, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates.</p>`, -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(`<h2 id="title">` + p.FormattedDisplayTitle() + `</h2>`)
|
||||||
|
}
|
||||||
|
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 := `<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-size: 120%;
|
||||||
|
font-family: ` + fontFam + `;
|
||||||
|
margin: 1em 2em;
|
||||||
|
}
|
||||||
|
#article {
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6, p, code {
|
||||||
|
display: inline
|
||||||
|
}
|
||||||
|
img, iframe, video {
|
||||||
|
max-width: 100%
|
||||||
|
}
|
||||||
|
#title {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.intro {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
div#footer {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 35em;
|
||||||
|
margin: 2em auto;
|
||||||
|
}
|
||||||
|
div#footer p {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.86em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
hr {
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
margin: 2em 1em;
|
||||||
|
}
|
||||||
|
p#emailsub {
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block !important;
|
||||||
|
width: 100%;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="article">` + title + `<p class="intro">From <a href="` + p.CanonicalURL(app.cfg.App.Host) + `">` + p.DisplayCanonicalURL() + `</a></p>
|
||||||
|
|
||||||
|
` + string(p.HTMLContent) + `</div>
|
||||||
|
<hr />
|
||||||
|
<div id="footer">
|
||||||
|
<p>Originally published on <a href="` + p.Collection.CanonicalURL() + `">` + p.Collection.DisplayTitle() + `</a>, a blog you subscribe to.</p>
|
||||||
|
<p>Sent to %recipient.to%. <a href="` + p.Collection.CanonicalURL() + `email/unsubscribe/%recipient.id%?t=%recipient.token%">Unsubscribe</a>.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
// 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(`<html>
|
||||||
|
<body style="font-family:Lora, 'Palatino Linotype', Palatino, Baskerville, 'Book Antiqua', 'New York', 'DejaVu serif', serif; font-size: 100%%; margin:1em 2em;">
|
||||||
|
<div style="font-size: 1.2em;">
|
||||||
|
<p>Confirm your subscription to <a href="` + c.CanonicalURL() + `">` + c.DisplayTitle() + `</a> to start receiving future posts:</p>
|
||||||
|
<p><a href="` + c.CanonicalURL() + `email/confirm/` + subID + `?t=` + token + `">Subscribe to ` + c.DisplayTitle() + `</a></p>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`)
|
||||||
|
gun.Send(m)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
12
go.mod
12
go.mod
|
@ -1,11 +1,17 @@
|
||||||
module github.com/writefreely/writefreely
|
module github.com/writefreely/writefreely
|
||||||
|
|
||||||
require (
|
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/clbanning/mxj v1.8.4 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.0
|
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/fatih/color v1.10.0
|
||||||
github.com/go-sql-driver/mysql v1.6.0
|
github.com/go-sql-driver/mysql v1.6.0
|
||||||
github.com/go-test/deep v1.0.1 // indirect
|
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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
|
||||||
github.com/gorilla/csrf v1.7.0
|
github.com/gorilla/csrf v1.7.0
|
||||||
github.com/gorilla/feeds v1.1.1
|
github.com/gorilla/feeds v1.1.1
|
||||||
|
@ -18,11 +24,14 @@ require (
|
||||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||||
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
|
||||||
github.com/lunixbochs/vtclean v1.0.0 // indirect
|
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/manifoldco/promptui v0.8.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.6
|
github.com/mattn/go-sqlite3 v1.14.6
|
||||||
github.com/microcosm-cc/bluemonday v1.0.5
|
github.com/microcosm-cc/bluemonday v1.0.5
|
||||||
github.com/mitchellh/go-wordwrap v1.0.1
|
github.com/mitchellh/go-wordwrap v1.0.1
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d
|
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/prologic/go-gopher v0.0.0-20200721020712-3e11dcff0469
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||||
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // 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/activity v0.1.2
|
||||||
github.com/writeas/activityserve v0.0.0-20200409150223-d7ab3eaa4481
|
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.0.1+incompatible
|
||||||
|
github.com/writeas/go-strip-markdown/v2 v2.1.1
|
||||||
github.com/writeas/go-webfinger v1.1.0
|
github.com/writeas/go-webfinger v1.1.0
|
||||||
github.com/writeas/httpsig v1.0.0
|
github.com/writeas/httpsig v1.0.0
|
||||||
github.com/writeas/impart v1.1.1
|
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/writeas/web-core v1.3.1-0.20210330164422-95a3a717ed8f
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0
|
github.com/writefreely/go-nodeinfo v1.2.0
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
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
|
gopkg.in/ini.v1 v1.62.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
111
go.sum
111
go.sum
|
@ -1,6 +1,10 @@
|
||||||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||||
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
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/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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
|
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/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 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
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 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
|
||||||
github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
|
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 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
|
||||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
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.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 h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8=
|
||||||
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE=
|
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 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
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 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
|
||||||
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
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 h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
||||||
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
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 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c=
|
||||||
github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U=
|
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 h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gorilla/csrf v1.7.0 h1:mMPjV5/3Zd460xCavIkppUdvnl5fPXMpv2uz2Zyg7/Y=
|
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.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 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
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 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
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 h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
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=
|
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 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 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
|
||||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
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 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo=
|
||||||
github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ=
|
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=
|
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/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 h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
|
||||||
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
|
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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/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 h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||||
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
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 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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=
|
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 h1:Ho+uVpkel/udgjbwB5Lktg9BtvJSh2DT0Hi6LPSyI2w=
|
||||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
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/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.6.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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/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 h1:IIqxTM5Jr7RzhigcL6FkrCNfXkvbR+Nbu1ls48pXYcw=
|
||||||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
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 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q=
|
||||||
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||||
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
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/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 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss=
|
||||||
github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg=
|
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-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-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-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 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
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-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-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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
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-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-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-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-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-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-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 h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
|
||||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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 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 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
|
||||||
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
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 h1:POO/ycCATvegFmVuPpQzZFJ+pGZeX22Ufu6fibxDVjU=
|
||||||
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
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.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 h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
72
jobs.go
Normal file
72
jobs.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -210,6 +210,10 @@ body {
|
||||||
pre {
|
pre {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.flash {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
&#subpage {
|
&#subpage {
|
||||||
#wrapper {
|
#wrapper {
|
||||||
|
@ -1596,6 +1600,18 @@ pre.code-block {
|
||||||
overflow-x: auto;
|
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 {
|
#org-nav {
|
||||||
font-family: @sansFont;
|
font-family: @sansFont;
|
||||||
font-size: 1.1em;
|
font-size: 1.1em;
|
||||||
|
|
|
@ -36,6 +36,13 @@ func (db *datastore) typeSmallInt() string {
|
||||||
return "SMALLINT"
|
return "SMALLINT"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *datastore) typeTinyInt() string {
|
||||||
|
if db.driverName == driverSQLite {
|
||||||
|
return "INTEGER"
|
||||||
|
}
|
||||||
|
return "TINYINT"
|
||||||
|
}
|
||||||
|
|
||||||
func (db *datastore) typeText() string {
|
func (db *datastore) typeText() string {
|
||||||
return "TEXT"
|
return "TEXT"
|
||||||
}
|
}
|
||||||
|
|
|
@ -65,7 +65,8 @@ var migrations = []Migration{
|
||||||
New("support oauth attach", oauthAttach), // V6 -> V7
|
New("support oauth attach", oauthAttach), // V6 -> V7
|
||||||
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
New("support oauth via invite", oauthInvites), // V7 -> V8 (v0.12.0)
|
||||||
New("optimize drafts retrieval", optimizeDrafts), // V8 -> V9
|
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
|
// CurrentVer returns the current migration version the application is on
|
||||||
|
|
60
migrations/v11.go
Normal file
60
migrations/v11.go
Normal file
|
@ -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
|
||||||
|
}
|
57
posts.go
57
posts.go
|
@ -14,6 +14,7 @@ import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/writefreely/writefreely/spam"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
@ -651,8 +652,17 @@ func newPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
// Write success now
|
// Write success now
|
||||||
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
|
response := impart.WriteSuccess(w, newPost, http.StatusCreated)
|
||||||
|
|
||||||
if newPost.Collection != nil && !app.cfg.App.Private && app.cfg.App.Federation && !newPost.Created.After(time.Now()) {
|
if newPost.Collection != nil {
|
||||||
go federatePost(app, newPost, newPost.Collection.ID, false)
|
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
|
return response
|
||||||
|
@ -952,16 +962,23 @@ func addPost(app *App, w http.ResponseWriter, r *http.Request) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !app.cfg.App.Private && app.cfg.App.Federation {
|
for _, pRes := range *res {
|
||||||
for _, pRes := range *res {
|
if pRes.Code != http.StatusOK {
|
||||||
if pRes.Code != http.StatusOK {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if !app.cfg.App.Private && app.cfg.App.Federation {
|
||||||
if !pRes.Post.Created.After(time.Now()) {
|
if !pRes.Post.Created.After(time.Now()) {
|
||||||
pRes.Post.Collection.hostName = app.cfg.App.Host
|
pRes.Post.Collection.hostName = app.cfg.App.Host
|
||||||
go federatePost(app, pRes.Post, pRes.Post.Collection.ID, false)
|
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)
|
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
|
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 {
|
func (p *PublicPost) ActivityObject(app *App) *activitystreams.Object {
|
||||||
cfg := app.cfg
|
cfg := app.cfg
|
||||||
var o *activitystreams.Object
|
var o *activitystreams.Object
|
||||||
|
@ -1530,6 +1556,15 @@ Are you sure it was ever here?`,
|
||||||
} else {
|
} else {
|
||||||
p.extractData()
|
p.extractData()
|
||||||
p.Content = strings.Replace(p.Content, "<!--more-->", "", 1)
|
p.Content = strings.Replace(p.Content, "<!--more-->", "", 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, "<!--emailsub-->", `<p id="emailsub">You're subscribed to email updates. <a href="/api/collections/`+c.Alias+`/email/unsubscribe?slug=`+p.Slug.String+`">Unsubscribe</a>.</p>`, -1)
|
||||||
|
} else {
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", `<form method="post" id="emailsub" action="/api/collections/`+c.Alias+`/email/subscribe"><input type="hidden" name="slug" value="`+p.Slug.String+`" /><input type="hidden" name="web" value="1" /><div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="`+spam.HoneypotFieldName()+`" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div><input type="email" name="email" placeholder="me@example.com" /><input type="submit" id="subscribe-btn" value="Subscribe" /></form>`, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Content = strings.Replace(p.Content, "<!--emailsub-->", "<!--emailsub-->", 1)
|
||||||
// TODO: move this to function
|
// TODO: move this to function
|
||||||
p.formatContent(app.cfg, cr.isCollOwner, true)
|
p.formatContent(app.cfg, cr.isCollOwner, true)
|
||||||
tp := CollectionPostPage{
|
tp := CollectionPostPage{
|
||||||
|
@ -1593,6 +1628,14 @@ func (p *Post) extractData() {
|
||||||
p.extractImages()
|
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 {
|
func (rp *RawPost) UserFacingCreated() string {
|
||||||
return rp.Created.Format(postMetaDateFormat)
|
return rp.Created.Format(postMetaDateFormat)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}/collect", handler.All(addPost)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
|
apiColls.HandleFunc("/{alias}/pin", handler.All(pinPost)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/unpin", 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}/inbox", handler.All(handleFetchCollectionInbox)).Methods("POST")
|
||||||
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
|
apiColls.HandleFunc("/{alias}/outbox", handler.AllReader(handleFetchCollectionOutbox)).Methods("GET")
|
||||||
apiColls.HandleFunc("/{alias}/following", handler.AllReader(handleFetchCollectionFollowing)).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("/tag:{tag}/feed/", handler.Web(ViewFeed, UserLevelReader))
|
||||||
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
|
r.HandleFunc("/sitemap.xml", handler.AllReader(handleViewSitemap))
|
||||||
r.HandleFunc("/feed/", handler.AllReader(ViewFeed))
|
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}", handler.CollectionPostOrStatic)
|
||||||
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
|
r.HandleFunc("/{slug}/edit", handler.Web(handleViewPad, UserLevelUser))
|
||||||
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
|
r.HandleFunc("/{slug}/edit/meta", handler.Web(handleViewMeta, UserLevelUser))
|
||||||
|
|
|
@ -21,6 +21,10 @@ import (
|
||||||
const (
|
const (
|
||||||
day = 86400
|
day = 86400
|
||||||
sessionLength = 180 * day
|
sessionLength = 180 * day
|
||||||
|
|
||||||
|
userEmailCookieName = "ue"
|
||||||
|
userEmailCookieVal = "email"
|
||||||
|
|
||||||
cookieName = "wfu"
|
cookieName = "wfu"
|
||||||
cookieUserVal = "u"
|
cookieUserVal = "u"
|
||||||
|
|
||||||
|
|
43
spam/email.go
Normal file
43
spam/email.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -102,6 +102,12 @@
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Flash}}
|
||||||
|
<div class="alert success flash">
|
||||||
|
<p>{{.Flash}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{template "posts" .}}
|
{{template "posts" .}}
|
||||||
|
|
||||||
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
{{if gt .TotalPages 1}}<nav id="paging" class="content-container clearfix">
|
||||||
|
@ -114,6 +120,8 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</nav>{{end}}
|
</nav>{{end}}
|
||||||
|
|
||||||
|
{{if not .IsWelcome}}{{template "emailsubscribe" .}}{{end}}
|
||||||
|
|
||||||
{{if .Posts}}</section>{{else}}</div>{{end}}
|
{{if .Posts}}</section>{{else}}</div>{{end}}
|
||||||
|
|
||||||
{{if .ShowFooterBranding }}
|
{{if .ShowFooterBranding }}
|
||||||
|
|
|
@ -102,3 +102,28 @@
|
||||||
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
|
<script type="text/javascript" id="MathJax-script" src="/js/mathjax/tex-svg-full.js" async>
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "emailsubscribe"}}
|
||||||
|
{{if .EmailSubsEnabled}}
|
||||||
|
<div id="emailsub">
|
||||||
|
{{if .IsSubscriber}}
|
||||||
|
<p>You're subscribed to email updates. <a href="/api/collections/{{.Alias}}/email/unsubscribe">Unsubscribe</a>.</p>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/api/collections/{{.Alias}}/email/subscribe">
|
||||||
|
<input type="hidden" name="web" value="1" />
|
||||||
|
<p>Enter your email to subscribe to updates.</p> <div style="position: absolute; left: -5000px;" aria-hidden="true"><input type="email" name="{{.Honeypot}}" tabindex="-1" value="" /><input type="password" name="fake_password" tabindex="-1" placeholder="password" autocomplete="new-password" /></div>
|
||||||
|
<input type="email" name="email" placeholder="me@example.com" />
|
||||||
|
<input type="submit" id="subscribe-btn" value="Subscribe" />
|
||||||
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
var $form = document.getElementById('emailsub').getElementsByTagName('form')[0];
|
||||||
|
$form.onsubmit = function() {
|
||||||
|
var $sub = document.getElementById('subscribe-btn');
|
||||||
|
$sub.disabled = true;
|
||||||
|
$sub.value = 'Subscribing...';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
|
@ -94,6 +94,44 @@ textarea.section.norm {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="option">
|
||||||
|
<h2 id="updates">Updates</h2>
|
||||||
|
<div class="section">
|
||||||
|
<p class="explain">Keep readers updated with your latest posts wherever they are.</p>
|
||||||
|
<ul style="list-style:none">
|
||||||
|
<li>
|
||||||
|
<label class="option-text"><input type="checkbox" checked="checked" disabled />
|
||||||
|
RSS feed
|
||||||
|
</label>
|
||||||
|
<p class="describe">Readers can subscribe to your blog's <a href="{{.CanonicalURL}}feed/" target="feed">RSS feed</a> with their favorite RSS reader.</p>
|
||||||
|
</li>
|
||||||
|
{{if .LettersCfg.Enabled}}
|
||||||
|
<li>
|
||||||
|
<label class="option-text" id="email-sub-label"><input type="checkbox" name="email_subs" id="email_subs" {{if .EmailSubsEnabled}}checked="checked"{{end}} />
|
||||||
|
Email subscriptions
|
||||||
|
</label>
|
||||||
|
<p class="describe">
|
||||||
|
Let readers subscribe to your blog via email, and optionally accept private replies.
|
||||||
|
</p>
|
||||||
|
<div id="custom-letter-reply" style="font-size: .8em; margin-top: -0.5em; margin-left: 1.8em; margin-bottom: 1em;" {{if not .EmailSubsEnabled}}style="display:none"{{end}}>
|
||||||
|
Allow replies to this address:
|
||||||
|
<input type="email" name="letter_reply" id="letter_reply" placeholder="me@example.com" value="{{.LetterReplyTo}}" {{if not .EmailSubsEnabled}}disabled{{end}} />
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
{{if .Federation}}
|
||||||
|
<li>
|
||||||
|
<label class="option-text" id="federate-label"><input type="checkbox" name="federate" id="federate" {{if .Federation}}checked="checked"{{end}} disabled />
|
||||||
|
Federation
|
||||||
|
</label>
|
||||||
|
<strong id="normal-handle-env" class="fedi-handle">@<span id="fedi-handle">{{.Alias}}</span>@<span id="fedi-domain">{{.FriendlyHost}}</span></strong>
|
||||||
|
<p class="describe">Allow others to follow your blog and interact with your posts in the fediverse. <a href="https://video.writeas.org/videos/watch/cc55e615-d204-417c-9575-7b57674cc6f3" target="video">See how it works</a>.</p>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="option">
|
<div class="option">
|
||||||
<h2>Display Format</h2>
|
<h2>Display Format</h2>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
|
@ -249,6 +287,13 @@ var $customDomain = document.getElementById('domain-alias');
|
||||||
var $customHandleEnv = document.getElementById('custom-handle-env');
|
var $customHandleEnv = document.getElementById('custom-handle-env');
|
||||||
var $normalHandleEnv = document.getElementById('normal-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) {
|
if (matchMedia('(pointer:fine)').matches) {
|
||||||
// Only initialize Ace editor on devices with a mouse
|
// Only initialize Ace editor on devices with a mouse
|
||||||
var opt = {
|
var opt = {
|
||||||
|
|
4
users.go
4
users.go
|
@ -134,3 +134,7 @@ func (u *User) IsAdmin() bool {
|
||||||
func (u *User) IsSilenced() bool {
|
func (u *User) IsSilenced() bool {
|
||||||
return u.Status&UserSilenced != 0
|
return u.Status&UserSilenced != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *User) IsEmailSubscriber(app *App, collID int64) bool {
|
||||||
|
return app.db.IsEmailSubscriber("", u.ID, collID)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue