mirror of
https://gitlab.com/commento/commento.git
synced 2025-06-28 22:55:39 -04:00
everywhere: add email notifications
This commit is contained in:
1
api/Gopkg.lock
generated
1
api/Gopkg.lock
generated
@ -161,6 +161,7 @@
|
||||
"github.com/op/go-logging",
|
||||
"github.com/russross/blackfriday",
|
||||
"golang.org/x/crypto/bcrypt",
|
||||
"golang.org/x/net/html",
|
||||
"golang.org/x/oauth2",
|
||||
"golang.org/x/oauth2/github",
|
||||
"golang.org/x/oauth2/google",
|
||||
|
@ -144,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": markdownToHtml(*x.Markdown)})
|
||||
// TODO: reuse html in commentNew and do only one markdown to HTML conversion?
|
||||
html := markdownToHtml(*x.Markdown)
|
||||
|
||||
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
|
||||
if smtpConfigured {
|
||||
go emailNotificationNew(d, path, commenterHex, commentHex, *x.ParentHex, state)
|
||||
}
|
||||
}
|
||||
|
@ -27,6 +27,10 @@ func commenterNew(email string, name string, link string, photo string, provider
|
||||
return "", errorEmailAlreadyExists
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
commenterHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
return "", errorInternal
|
||||
|
66
api/cron_email_notification.go
Normal file
66
api/cron_email_notification.go
Normal file
@ -0,0 +1,66 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func emailNotificationBegin() error {
|
||||
go func() {
|
||||
for {
|
||||
statement := `
|
||||
SELECT email, sendModeratorNotifications, sendReplyNotifications
|
||||
FROM emails
|
||||
WHERE pendingEmails > 0 AND lastEmailNotificationDate < $1;
|
||||
`
|
||||
rows, err := db.Query(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
|
||||
if err != nil {
|
||||
logger.Errorf("cannot query domains: %v", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
var sendModeratorNotifications bool
|
||||
var sendReplyNotifications bool
|
||||
if err = rows.Scan(&email, &sendModeratorNotifications, &sendReplyNotifications); err != nil {
|
||||
logger.Errorf("cannot scan email in cron job to send notifications: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := emailQueue[email]; !ok {
|
||||
if err = emailNotificationPendingReset(email); err != nil {
|
||||
logger.Errorf("error resetting pendingEmails: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
cont := true
|
||||
kindListMap := map[string][]emailNotification{}
|
||||
for cont {
|
||||
select {
|
||||
case e := <-emailQueue[email]:
|
||||
if _, ok := kindListMap[e.Kind]; !ok {
|
||||
kindListMap[e.Kind] = []emailNotification{}
|
||||
}
|
||||
|
||||
if (e.Kind == "reply" && sendReplyNotifications) || sendModeratorNotifications {
|
||||
kindListMap[e.Kind] = append(kindListMap[e.Kind], e)
|
||||
}
|
||||
default:
|
||||
cont = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for kind, list := range kindListMap {
|
||||
go emailNotificationSend(email, kind, list)
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Minute)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
@ -6,6 +6,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
var goMigrations = map[string](func() error){
|
||||
"20190213033530-email-notifications.sql": migrateEmails,
|
||||
}
|
||||
|
||||
func migrate() error {
|
||||
return migrateFromDir(os.Getenv("STATIC") + "/db")
|
||||
}
|
||||
@ -69,6 +73,13 @@ func migrateFromDir(dir string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if fn, ok := goMigrations[file.Name()]; ok {
|
||||
if err = fn(); err != nil {
|
||||
logger.Errorf("cannot execute Go migration associated with SQL %s: %v", f, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
completed++
|
||||
}
|
||||
}
|
||||
|
37
api/database_migrate_email_notifications.go
Normal file
37
api/database_migrate_email_notifications.go
Normal file
@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func migrateEmails() error {
|
||||
statement := `
|
||||
SELECT commenters.email
|
||||
FROM commenters
|
||||
UNION
|
||||
SELECT owners.email
|
||||
FROM owners
|
||||
UNION
|
||||
SELECT moderators.email
|
||||
FROM moderators;
|
||||
`
|
||||
rows, err := db.Query(statement)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get comments: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err = rows.Scan(&email); err != nil {
|
||||
logger.Errorf("cannot get email from tables during migration: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
|
||||
if err = emailNew(email); err != nil {
|
||||
logger.Errorf("cannot insert email during migration: %v", err)
|
||||
return errorDatabaseMigration
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -5,15 +5,16 @@ import (
|
||||
)
|
||||
|
||||
type domain struct {
|
||||
Domain string `json:"domain"`
|
||||
OwnerHex string `json:"ownerHex"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
State string `json:"state"`
|
||||
ImportedComments bool `json:"importedComments"`
|
||||
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||
RequireModeration bool `json:"requireModeration"`
|
||||
RequireIdentification bool `json:"requireIdentification"`
|
||||
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
||||
Moderators []moderator `json:"moderators"`
|
||||
Domain string `json:"domain"`
|
||||
OwnerHex string `json:"ownerHex"`
|
||||
Name string `json:"name"`
|
||||
CreationDate time.Time `json:"creationDate"`
|
||||
State string `json:"state"`
|
||||
ImportedComments bool `json:"importedComments"`
|
||||
AutoSpamFilter bool `json:"autoSpamFilter"`
|
||||
RequireModeration bool `json:"requireModeration"`
|
||||
RequireIdentification bool `json:"requireIdentification"`
|
||||
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
|
||||
Moderators []moderator `json:"moderators"`
|
||||
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ func domainGet(dmn string) (domain, error) {
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
|
||||
FROM domains
|
||||
WHERE domain = $1;
|
||||
`
|
||||
@ -16,7 +16,7 @@ func domainGet(dmn string) (domain, error) {
|
||||
|
||||
var err error
|
||||
d := domain{}
|
||||
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
|
||||
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
|
||||
return d, errorNoSuchDomain
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
|
||||
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
|
||||
FROM domains
|
||||
WHERE ownerHex=$1;
|
||||
`
|
||||
@ -24,7 +24,7 @@ func domainList(ownerHex string) ([]domain, error) {
|
||||
domains := []domain{}
|
||||
for rows.Next() {
|
||||
d := domain{}
|
||||
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
|
||||
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
|
||||
logger.Errorf("cannot Scan domain: %v", err)
|
||||
return nil, errorInternal
|
||||
}
|
||||
|
@ -10,6 +10,11 @@ func domainModeratorNew(domain string, email string) error {
|
||||
return errorMissingField
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
logger.Errorf("cannot create email when creating moderator: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
moderators (domain, email, addDate)
|
||||
|
@ -7,11 +7,11 @@ import (
|
||||
func domainUpdate(d domain) error {
|
||||
statement := `
|
||||
UPDATE domains
|
||||
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7
|
||||
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7, emailNotificationPolicy=$8
|
||||
WHERE domain=$1;
|
||||
`
|
||||
|
||||
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous)
|
||||
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous, d.EmailNotificationPolicy)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update non-moderators: %v", err)
|
||||
return errorInternal
|
||||
|
14
api/email.go
Normal file
14
api/email.go
Normal file
@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type email struct {
|
||||
Email string
|
||||
UnsubscribeSecretHex string
|
||||
LastEmailNotificationDate time.Time
|
||||
PendingEmails int
|
||||
SendReplyNotifications bool
|
||||
SendModeratorNotifications bool
|
||||
}
|
20
api/email_get.go
Normal file
20
api/email_get.go
Normal file
@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func emailGet(em string) (email, error) {
|
||||
statement := `
|
||||
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
|
||||
FROM emails
|
||||
WHERE email = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, em)
|
||||
|
||||
e := email{}
|
||||
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
|
||||
// TODO: is this the only error?
|
||||
return e, errorNoSuchEmail
|
||||
}
|
||||
|
||||
return e, nil
|
||||
}
|
26
api/email_new.go
Normal file
26
api/email_new.go
Normal file
@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func emailNew(email string) error {
|
||||
unsubscribeSecretHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
statement := `
|
||||
INSERT INTO
|
||||
emails (email, unsubscribeSecretHex, lastEmailNotificationDate)
|
||||
VALUES ($1, $2, $3 )
|
||||
ON CONFLICT DO NOTHING;
|
||||
`
|
||||
_, err = db.Exec(statement, email, unsubscribeSecretHex, time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Errorf("cannot insert email into emails: %v", err)
|
||||
return errorInternal
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
81
api/email_notification.go
Normal file
81
api/email_notification.go
Normal file
@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type emailNotification struct {
|
||||
Email string
|
||||
CommenterName string
|
||||
Domain string
|
||||
Path string
|
||||
Title string
|
||||
CommentHex string
|
||||
Kind string
|
||||
}
|
||||
|
||||
var emailQueue map[string](chan emailNotification) = map[string](chan emailNotification){}
|
||||
|
||||
func emailNotificationPendingResetAll() error {
|
||||
statement := `
|
||||
UPDATE emails
|
||||
SET pendingEmails = 0;
|
||||
`
|
||||
_, err := db.Exec(statement)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot reset pendingEmails: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func emailNotificationPendingIncrement(email string) error {
|
||||
statement := `
|
||||
UPDATE emails
|
||||
SET pendingEmails = pendingEmails + 1
|
||||
WHERE email = $1;
|
||||
`
|
||||
_, err := db.Exec(statement, email)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot increment pendingEmails: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func emailNotificationPendingReset(email string) error {
|
||||
statement := `
|
||||
UPDATE emails
|
||||
SET pendingEmails = 0, lastEmailNotificationDate = $2
|
||||
WHERE email = $1;
|
||||
`
|
||||
_, err := db.Exec(statement, email, time.Now().UTC())
|
||||
if err != nil {
|
||||
logger.Errorf("cannot decrement pendingEmails: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func emailNotificationEnqueue(e emailNotification) error {
|
||||
if err := emailNotificationPendingIncrement(e.Email); err != nil {
|
||||
logger.Errorf("cannot increment pendingEmails when enqueueing: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if _, ok := emailQueue[e.Email]; !ok {
|
||||
// don't enqueue more than 10 emails as we won't send more than 10 comments
|
||||
// in one email anyway
|
||||
emailQueue[e.Email] = make(chan emailNotification, 10)
|
||||
}
|
||||
|
||||
select {
|
||||
case emailQueue[e.Email] <- e:
|
||||
default:
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
138
api/email_notification_new.go
Normal file
138
api/email_notification_new.go
Normal file
@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, state string) {
|
||||
if d.EmailNotificationPolicy == "none" {
|
||||
return
|
||||
}
|
||||
|
||||
// We'll need to check again when we're sending in case the comment was
|
||||
// approved midway anyway.
|
||||
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
|
||||
return
|
||||
}
|
||||
|
||||
var commenterName string
|
||||
var commenterEmail string
|
||||
if commenterHex == "anonymous" {
|
||||
commenterName = "Anonymous"
|
||||
} else {
|
||||
c, err := commenterGetByHex(commenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
commenterName = c.Name
|
||||
commenterEmail = c.Email
|
||||
}
|
||||
|
||||
kind := d.EmailNotificationPolicy
|
||||
if state != "approved" {
|
||||
kind = "pending-moderation"
|
||||
}
|
||||
|
||||
for _, m := range d.Moderators {
|
||||
// Do not email the commenting moderator their own comment.
|
||||
if commenterHex != "anonymous" && m.Email == commenterEmail {
|
||||
continue
|
||||
}
|
||||
|
||||
emailNotificationPendingIncrement(m.Email)
|
||||
emailNotificationEnqueue(emailNotification{
|
||||
Email: m.Email,
|
||||
CommenterName: commenterName,
|
||||
Domain: d.Domain,
|
||||
Path: path,
|
||||
Title: title,
|
||||
CommentHex: commentHex,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, parentHex string, state string) {
|
||||
// No reply notifications for root comments.
|
||||
if parentHex == "root" {
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification emails for unapproved comments.
|
||||
if state != "approved" {
|
||||
return
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT commenterHex
|
||||
FROM comments
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, parentHex)
|
||||
|
||||
var parentCommenterHex string
|
||||
err := row.Scan(&parentCommenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot scan commenterHex and parentCommenterHex: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification emails for anonymous users.
|
||||
if parentCommenterHex == "anonymous" {
|
||||
return
|
||||
}
|
||||
|
||||
// No reply notification email for self replies.
|
||||
if parentCommenterHex == commenterHex {
|
||||
return
|
||||
}
|
||||
|
||||
pc, err := commenterGetByHex(parentCommenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var commenterName string
|
||||
if commenterHex == "anonymous" {
|
||||
commenterName = "Anonymous"
|
||||
} else {
|
||||
c, err := commenterGetByHex(commenterHex)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get commenter to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
commenterName = c.Name
|
||||
}
|
||||
|
||||
// We'll check if they want to receive reply notifications later at the time
|
||||
// of sending.
|
||||
emailNotificationEnqueue(emailNotification{
|
||||
Email: pc.Email,
|
||||
CommenterName: commenterName,
|
||||
Domain: d.Domain,
|
||||
Path: path,
|
||||
Title: title,
|
||||
CommentHex: commentHex,
|
||||
Kind: "reply",
|
||||
})
|
||||
}
|
||||
|
||||
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, parentHex string, state string) {
|
||||
p, err := pageGet(d.Domain, path)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get page to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if p.Title == "" {
|
||||
p.Title, err = pageTitleUpdate(d.Domain, path)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update/get page title to send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, state)
|
||||
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, parentHex, state)
|
||||
}
|
63
api/email_notification_send.go
Normal file
63
api/email_notification_send.go
Normal file
@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
)
|
||||
|
||||
func emailNotificationSend(em string, kind string, notifications []emailNotification) {
|
||||
if len(notifications) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
e, err := emailGet(em)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot get email: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
messages := []emailNotificationText{}
|
||||
for _, notification := range notifications {
|
||||
statement := `
|
||||
SELECT html
|
||||
FROM comments
|
||||
WHERE commentHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, notification.CommentHex)
|
||||
|
||||
var html string
|
||||
if err = row.Scan(&html); err != nil {
|
||||
// the comment was deleted?
|
||||
// TODO: is this the only error?
|
||||
return
|
||||
}
|
||||
|
||||
messages = append(messages, emailNotificationText{
|
||||
emailNotification: notification,
|
||||
Html: template.HTML(html),
|
||||
})
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT name
|
||||
FROM commenters
|
||||
WHERE email = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, em)
|
||||
|
||||
var name string
|
||||
if err := row.Scan(&name); err != nil {
|
||||
// The moderator has probably not created a commenter account. Let's just
|
||||
// use their email as name.
|
||||
name = nameFromEmail(em)
|
||||
}
|
||||
|
||||
if err := emailNotificationPendingReset(em); err != nil {
|
||||
logger.Errorf("cannot reset after email notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := smtpEmailNotification(em, name, e.UnsubscribeSecretHex, messages, kind); err != nil {
|
||||
logger.Errorf("cannot send email notification: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
@ -42,3 +42,4 @@ var errorInvalidConfigFile = errors.New("Invalid config file.")
|
||||
var errorInvalidConfigValue = errors.New("Invalid config value.")
|
||||
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
|
||||
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
|
||||
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")
|
||||
|
@ -9,6 +9,8 @@ func main() {
|
||||
exitIfError(smtpTemplatesLoad())
|
||||
exitIfError(oauthConfigure())
|
||||
exitIfError(markdownRendererCreate())
|
||||
exitIfError(emailNotificationPendingResetAll())
|
||||
exitIfError(emailNotificationBegin())
|
||||
exitIfError(sigintCleanupSetup())
|
||||
exitIfError(versionCheckStart())
|
||||
exitIfError(domainExportCleanupBegin())
|
||||
|
@ -46,3 +46,24 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func ownerGetByOwnerHex(ownerHex string) (owner, error) {
|
||||
if ownerHex == "" {
|
||||
return owner{}, errorMissingField
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT ownerHex, email, name, confirmedEmail, joinDate
|
||||
FROM owners
|
||||
WHERE ownerHex = $1;
|
||||
`
|
||||
row := db.QueryRow(statement, ownerHex)
|
||||
|
||||
var o owner
|
||||
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
|
||||
logger.Errorf("cannot scan owner: %v\n", err)
|
||||
return owner{}, errorInternal
|
||||
}
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
@ -20,6 +20,10 @@ func ownerNew(email string, name string, password string) (string, error) {
|
||||
return "", errorEmailAlreadyExists
|
||||
}
|
||||
|
||||
if err := emailNew(email); err != nil {
|
||||
return "", errorInternal
|
||||
}
|
||||
|
||||
ownerHex, err := randomHex(32)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot generate ownerHex: %v", err)
|
||||
|
@ -8,4 +8,5 @@ type page struct {
|
||||
IsLocked bool `json:"isLocked"`
|
||||
CommentCount int `json:"commentCount"`
|
||||
StickyCommentHex string `json:"stickyCommentHex"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
@ -11,14 +11,14 @@ func pageGet(domain string, path string) (page, error) {
|
||||
}
|
||||
|
||||
statement := `
|
||||
SELECT isLocked, commentCount, stickyCommentHex
|
||||
SELECT isLocked, commentCount, stickyCommentHex, title
|
||||
FROM pages
|
||||
WHERE domain=$1 AND path=$2;
|
||||
`
|
||||
row := db.QueryRow(statement, domain, path)
|
||||
|
||||
p := page{Domain: domain, Path: path}
|
||||
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex); err != nil {
|
||||
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
// If there haven't been any comments, there won't be a record for this
|
||||
// page. The sane thing to do is return defaults.
|
||||
@ -26,6 +26,7 @@ func pageGet(domain string, path string) (page, error) {
|
||||
p.IsLocked = false
|
||||
p.CommentCount = 0
|
||||
p.StickyCommentHex = "none"
|
||||
p.Title = ""
|
||||
} else {
|
||||
logger.Errorf("error scanning page: %v", err)
|
||||
return page{}, errorInternal
|
||||
|
28
api/page_title.go
Normal file
28
api/page_title.go
Normal file
@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import ()
|
||||
|
||||
func pageTitleUpdate(domain string, path string) (string, error) {
|
||||
title, err := htmlTitleGet("http://" + domain + path)
|
||||
if err != nil {
|
||||
// This could fail due to a variety of reasons that we can't control such
|
||||
// as the user's URL 404 or something, so let's not pollute the error log
|
||||
// with messages. Just use a sane title. Maybe we'll have the ability to
|
||||
// retry later.
|
||||
logger.Errorf("%v", err)
|
||||
title = domain
|
||||
}
|
||||
|
||||
statement := `
|
||||
UPDATE pages
|
||||
SET title = $3
|
||||
WHERE domain = $1 AND path = $2;
|
||||
`
|
||||
_, err = db.Exec(statement, domain, path, title)
|
||||
if err != nil {
|
||||
logger.Errorf("cannot update pages table with title: %v", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return title, nil
|
||||
}
|
@ -27,6 +27,7 @@ func apiRouterInit(router *mux.Router) error {
|
||||
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
|
||||
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
|
||||
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
|
||||
router.HandleFunc("/api/commenter/unsubscribe", commenterSelfHandler).Methods("GET")
|
||||
|
||||
router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET")
|
||||
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")
|
||||
|
86
api/smtp_email_notification.go
Normal file
86
api/smtp_email_notification.go
Normal file
@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
ht "html/template"
|
||||
"net/smtp"
|
||||
"os"
|
||||
tt "text/template"
|
||||
)
|
||||
|
||||
type emailNotificationText struct {
|
||||
emailNotification
|
||||
Html ht.HTML
|
||||
}
|
||||
|
||||
type emailNotificationPlugs struct {
|
||||
Origin string
|
||||
Kind string
|
||||
Subject string
|
||||
UnsubscribeSecretHex string
|
||||
Notifications []emailNotificationText
|
||||
}
|
||||
|
||||
func smtpEmailNotification(to string, toName string, unsubscribeSecretHex string, notifications []emailNotificationText, kind string) error {
|
||||
var subject string
|
||||
if kind == "reply" {
|
||||
var verb string
|
||||
if len(notifications) > 1 {
|
||||
verb = "replies"
|
||||
} else {
|
||||
verb = "reply"
|
||||
}
|
||||
subject = fmt.Sprintf("%d new comment %s", len(notifications), verb)
|
||||
} else {
|
||||
var verb string
|
||||
if len(notifications) > 1 {
|
||||
verb = "comments"
|
||||
} else {
|
||||
verb = "comment"
|
||||
}
|
||||
if kind == "pending-moderation" {
|
||||
subject = fmt.Sprintf("%d new %s pending moderation", len(notifications), verb)
|
||||
} else {
|
||||
subject = fmt.Sprintf("%d new %s on your website", len(notifications), verb)
|
||||
}
|
||||
}
|
||||
|
||||
h, err := tt.New("header").Parse(`MIME-Version: 1.0
|
||||
From: Commento <{{.FromAddress}}>
|
||||
To: {{.ToName}} <{{.ToAddress}}>
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Subject: {{.Subject}}
|
||||
|
||||
`)
|
||||
|
||||
var header bytes.Buffer
|
||||
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + subject})
|
||||
|
||||
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
|
||||
if err != nil {
|
||||
logger.Errorf("cannot parse %s/templates/email-notification.txt: %v", os.Getenv("STATIC"), err)
|
||||
return errorMalformedTemplate
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
err = t.Execute(&body, &emailNotificationPlugs{
|
||||
Origin: os.Getenv("ORIGIN"),
|
||||
Kind: kind,
|
||||
Subject: subject,
|
||||
UnsubscribeSecretHex: unsubscribeSecretHex,
|
||||
Notifications: notifications,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Errorf("error generating templated HTML for email notification: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
|
||||
if err != nil {
|
||||
logger.Errorf("cannot send email notification: %v", err)
|
||||
return errorCannotSendEmail
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -31,7 +31,12 @@ Subject: {{.Subject}}
|
||||
return errorMalformedTemplate
|
||||
}
|
||||
|
||||
names := []string{"confirm-hex", "reset-hex", "domain-export", "domain-export-error"}
|
||||
names := []string{
|
||||
"confirm-hex",
|
||||
"reset-hex",
|
||||
"domain-export",
|
||||
"domain-export-error",
|
||||
}
|
||||
|
||||
templates = make(map[string]*template.Template)
|
||||
|
||||
|
36
api/utils_html.go
Normal file
36
api/utils_html.go
Normal file
@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"golang.org/x/net/html"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func htmlTitleRecurse(h *html.Node) string {
|
||||
if h.Type == html.ElementNode && h.Data == "title" {
|
||||
return h.FirstChild.Data
|
||||
}
|
||||
|
||||
for c := h.FirstChild; c != nil; c = c.NextSibling {
|
||||
res := htmlTitleRecurse(c)
|
||||
if res != "" {
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func htmlTitleGet(url string) (string, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
h, err := html.Parse(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return htmlTitleRecurse(h), nil
|
||||
}
|
@ -10,6 +10,16 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
|
||||
return append(a.Bytes(), b.Bytes()...)
|
||||
}
|
||||
|
||||
func nameFromEmail(email string) string {
|
||||
for i, c := range email {
|
||||
if c == '@' {
|
||||
return email[:i]
|
||||
}
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
func exitIfError(err error) {
|
||||
if err != nil {
|
||||
fmt.Printf("fatal error: %v\n", err)
|
||||
|
Reference in New Issue
Block a user