/ *
* 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 . 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 : 1 em 2 em ;
}
# article {
line - height : 1.5 ;
margin : 1.5 em 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 : 1 em ;
display : block ;
}
. intro {
font - style : italic ;
font - size : 0.95 em ;
}
div # footer {
text - align : center ;
max - width : 35 em ;
margin : 2 em auto ;
}
div # footer p {
display : block ;
font - size : 0.86 em ;
color : # 666 ;
}
hr {
border : 1 px solid # ccc ;
margin : 2 em 1 em ;
}
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
}