Merge pull request #478 from writefreely/letters

Support email subscriptions
This commit is contained in:
Matt Baer 2023-10-03 10:50:34 -04:00 committed by GitHub
commit 64dcb56793
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1253 additions and 18 deletions

View File

@ -875,12 +875,19 @@ func viewEditCollection(app *App, u *User, w http.ResponseWriter, r *http.Reques
*UserPage *UserPage
*Collection *Collection
Silenced bool Silenced bool
config.EmailCfg
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,
EmailCfg: app.cfg.Email,
} }
obj.UserPage.CollAlias = c.Alias obj.UserPage.CollAlias = c.Alias
if obj.EmailCfg.Enabled() {
obj.LetterReplyTo = app.db.GetCollectionAttribute(c.ID, collAttrLetterReplyTo)
}
showUserPage(w, "collection", obj) showUserPage(w, "collection", obj)
return nil return nil

11
app.go
View File

@ -428,6 +428,17 @@ func Initialize(apper Apper, debug bool) (*App, error) {
initActivityPub(apper.App()) initActivityPub(apper.App())
if apper.App().cfg.Email.Domain != "" || apper.App().cfg.Email.MailgunPrivate != "" {
if apper.App().cfg.Email.Domain == "" {
log.Error("[FAILED] Starting publish jobs queue: no [letters]domain config value set.")
} else if apper.App().cfg.Email.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...")

View File

@ -35,9 +35,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
@ -91,6 +94,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
@ -102,6 +106,7 @@ type (
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"`
Verification *string `schema:"verification_link" json:"verification_link"` Verification *string `schema:"verification_link" json:"verification_link"`
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"`
} }
@ -361,6 +366,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 ""
@ -612,13 +621,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
@ -882,14 +895,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

View File

@ -170,11 +170,17 @@ type (
DisablePasswordAuth bool `ini:"disable_password_auth"` DisablePasswordAuth bool `ini:"disable_password_auth"`
} }
EmailCfg 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"`
Email EmailCfg `ini:"email"`
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 EmailCfg) 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 ""

View File

@ -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"
@ -973,6 +974,40 @@ func (db *datastore) UpdateCollection(app *App, c *SubmittedCollection, alias st
} }
} }
// Update EmailSub value
if c.EmailSubs {
err = db.SetCollectionAttribute(collID, "email_subs", "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.SetCollectionAttribute(collID, collAttrLetterReplyTo, *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...)
@ -2968,3 +3003,247 @@ 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 (?, ?, ?, ?, "+db.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) {
timeWhere := "created < DATE_SUB(NOW(), INTERVAL delay MINUTE) AND created > DATE_SUB(NOW(), INTERVAL delay + 5 MINUTE)"
if db.driverName == driverSQLite {
timeWhere = "created < DATETIME('now', '-' || delay || ' MINUTE') AND created > DATETIME('now', '-' || (delay+5) || ' MINUTE')"
}
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 `+timeWhere+`
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
}

462
email.go Normal file
View File

@ -0,0 +1,462 @@
/*
* Copyright © 2019-2021 Musing Studio 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}
}
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 {
err = sendSubConfirmEmail(app, c, ss.Email, es.ID, es.Token)
if err != nil {
log.Error("Failed to send subscription confirmation email: %s", err)
return err
}
}
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.Email.Domain, app.cfg.Email.MailgunPrivate)
m := mailgun.NewMessage(p.Collection.DisplayTitle()+" <"+p.Collection.Alias+"@"+app.cfg.Email.Domain+">", stripmd.Strip(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
}
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)
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)
}
}
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.Email.Domain, app.cfg.Email.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.Email.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
}

25
go.mod
View File

@ -1,10 +1,19 @@
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/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
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.15.0 github.com/fatih/color v1.15.0
github.com/go-ini/ini v1.67.0 github.com/go-ini/ini v1.67.0
github.com/go-sql-driver/mysql v1.7.1 github.com/go-sql-driver/mysql v1.7.1
github.com/go-test/deep v1.0.1 // indirect
github.com/gobuffalo/envy v1.9.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/csrf v1.7.1 github.com/gorilla/csrf v1.7.1
github.com/gorilla/feeds v1.1.1 github.com/gorilla/feeds v1.1.1
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
@ -14,11 +23,17 @@ require (
github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-multierror v1.1.1
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec
github.com/mailgun/mailgun-go v2.0.0+incompatible
github.com/manifoldco/promptui v0.9.0 github.com/manifoldco/promptui v0.9.0
github.com/mattn/go-sqlite3 v1.14.17 github.com/mattn/go-sqlite3 v1.14.17
github.com/microcosm-cc/bluemonday v1.0.25 github.com/microcosm-cc/bluemonday v1.0.25
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/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/stretchr/testify v1.8.4 github.com/stretchr/testify v1.8.4
github.com/urfave/cli/v2 v2.25.7 github.com/urfave/cli/v2 v2.25.7
github.com/writeas/activity v0.1.2 github.com/writeas/activity v0.1.2
@ -40,35 +55,31 @@ require (
require ( require (
code.as/core/socks v1.0.0 // indirect code.as/core/socks v1.0.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/andybalholm/cascadia v1.1.0 // indirect
github.com/beevik/etree v1.1.0 // indirect github.com/beevik/etree v1.1.0 // indirect
github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect
github.com/fatih/structs v1.1.0 // indirect github.com/fatih/structs v1.1.0 // indirect
github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe // indirect
github.com/go-test/deep v1.0.1 // indirect
github.com/gofrs/uuid v3.3.0+incompatible // indirect github.com/gofrs/uuid v3.3.0+incompatible // indirect
github.com/gologme/log v1.2.0 // indirect github.com/gologme/log v1.2.0 // indirect
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.0 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/joho/godotenv v1.3.0 // indirect
github.com/jtolds/gls v4.2.1+incompatible // indirect github.com/jtolds/gls v4.2.1+incompatible // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.17 // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect github.com/rogpeppe/go-internal v1.3.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/smartystreets/assertions v0.0.0-20190116191733-b6c0e53d7304 // indirect
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c // indirect
github.com/writeas/go-writeas/v2 v2.0.2 // indirect github.com/writeas/go-writeas/v2 v2.0.2 // indirect
github.com/writeas/openssl-go v1.0.0 // indirect github.com/writeas/openssl-go v1.0.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect

96
go.sum
View File

@ -1,5 +1,9 @@
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/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=
@ -24,10 +28,19 @@ 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.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
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=
@ -35,12 +48,28 @@ github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
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/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/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.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE= github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
@ -65,10 +94,14 @@ 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -76,6 +109,8 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec h1:ZXWuspqypleMuJy4bzYEqlMhJnGAYpLrWe5p7W3CdvI=
github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g= github.com/kylemcc/twitter-text-go v0.0.0-20180726194232-7f582f6736ec/go.mod h1:voECJzdraJmolzPBgL9Z7ANwXf4oMXaTCsIkdiPpR/g=
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.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
@ -92,6 +127,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/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -100,6 +147,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0=
@ -111,6 +160,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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@ -153,31 +204,53 @@ github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAv
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/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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-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/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
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-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/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/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-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-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-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/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -194,6 +267,7 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@ -202,17 +276,39 @@ golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/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/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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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.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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

72
jobs.go Normal file
View 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
}

View File

@ -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 {
@ -1597,6 +1601,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;

View File

@ -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,6 +72,15 @@ func (db *datastore) typeDateTime() string {
return "DATETIME" return "DATETIME"
} }
func (db *datastore) typeIntPrimaryKey() string {
if db.driverName == driverSQLite {
// From docs: "In SQLite, a column with type INTEGER PRIMARY KEY is an alias for the ROWID (except in WITHOUT
// ROWID tables) which is always a 64-bit signed integer."
return "INTEGER PRIMARY KEY"
}
return "INT AUTO_INCREMENT PRIMARY KEY"
}
func (db *datastore) collateMultiByte() string { func (db *datastore) collateMultiByte() string {
if db.driverName == driverSQLite { if db.driverName == driverSQLite {
return "" return ""

View File

@ -65,9 +65,10 @@ 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("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11 New("Widen oauth_users.access_token", widenOauthAcceesToken), // V10 -> V11
New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 New("support verifying fedi profile", fediverseVerifyProfile), // V11 -> V12 (v0.14.0)
New("support newsletters", supportLetters), // V12 -> V13
} }
// CurrentVer returns the current migration version the application is on // CurrentVer returns the current migration version the application is on

58
migrations/v13.go Normal file
View File

@ -0,0 +1,58 @@
/*
* Copyright © 2021 Musing Studio 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.typeIntPrimaryKey() + `,
post_id ` + db.typeVarChar(16) + ` not null,
action ` + db.typeVarChar(16) + ` not null,
delay ` + db.typeTinyInt() + ` not null
)`)
if err != nil {
t.Rollback()
return err
}
_, err = t.Exec(`CREATE TABLE emailsubscribers (
id ` + db.typeChar(8) + ` not null,
collection_id ` + db.typeInt() + ` not null,
user_id ` + db.typeInt() + ` null,
email ` + db.typeVarChar(255) + ` null,
subscribed ` + db.typeDateTime() + ` not null,
token ` + db.typeChar(16) + ` not null,
confirmed ` + db.typeBool() + ` default 0 not null,
allow_export ` + db.typeBool() + ` 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
}

View File

@ -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"
@ -652,8 +653,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.Email.Enabled() && newPost.Collection.EmailSubsEnabled() {
go app.db.InsertJob(&PostJob{
PostID: newPost.ID,
Action: "email",
Delay: emailSendDelay,
})
}
} }
return response return response
@ -953,16 +963,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.Email.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
@ -1532,6 +1558,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.Email.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, "&lt;!--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{
@ -1596,6 +1631,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)
} }

View File

@ -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")
@ -223,6 +226,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))

View File

@ -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
View File

@ -0,0 +1,43 @@
/*
* Copyright © 2020-2021 Musing Studio 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
}

View File

@ -103,6 +103,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">
@ -115,6 +121,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 }}

View File

@ -105,3 +105,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}}

View File

@ -90,6 +90,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 .EmailCfg.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">
@ -254,6 +292,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 = {

View File

@ -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)
}