Compare commits
2 Commits
fd1494cf75
...
87fbdea68b
Author | SHA1 | Date | |
---|---|---|---|
87fbdea68b | |||
ceb42aa4d0 |
1
go.mod
1
go.mod
|
@ -14,4 +14,5 @@ require (
|
|||
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470
|
||||
github.com/urfave/negroni v1.0.0
|
||||
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
)
|
||||
|
|
1
go.sum
1
go.sum
|
@ -179,6 +179,7 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
|
|||
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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
|
|
@ -36,7 +36,11 @@ func NewSite(pubroot string) *Site {
|
|||
|
||||
// Register blog global template functions.
|
||||
responses.ExtraFuncs = template.FuncMap{
|
||||
"BlogIndex": controllers.PartialBlogIndex,
|
||||
"BlogIndex": controllers.PartialBlogIndex,
|
||||
"RenderComments": controllers.RenderComments,
|
||||
"RenderCommentsRO": controllers.RenderCommentsRO,
|
||||
"RenderComment": controllers.RenderComment,
|
||||
"RenderCommentForm": controllers.RenderCommentForm,
|
||||
}
|
||||
|
||||
return site
|
||||
|
|
252
pkg/controllers/comments.go
Normal file
252
pkg/controllers/comments.go
Normal file
|
@ -0,0 +1,252 @@
|
|||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gophertype/pkg/authentication"
|
||||
"git.kirsle.net/apps/gophertype/pkg/glue"
|
||||
"git.kirsle.net/apps/gophertype/pkg/mail"
|
||||
"git.kirsle.net/apps/gophertype/pkg/models"
|
||||
"git.kirsle.net/apps/gophertype/pkg/responses"
|
||||
"git.kirsle.net/apps/gophertype/pkg/session"
|
||||
"github.com/albrow/forms"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
func init() {
|
||||
glue.Register(glue.Endpoint{
|
||||
Path: "/comments",
|
||||
Methods: []string{"POST"},
|
||||
Handler: PostComment,
|
||||
})
|
||||
glue.Register(glue.Endpoint{
|
||||
Path: "/comments/quick-delete",
|
||||
Methods: []string{"GET"},
|
||||
Handler: CommentQuickDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// RenderComments returns the partial comments HTML to embed on a page.
|
||||
func RenderComments(w http.ResponseWriter, r *http.Request, subject string, ids ...string) template.HTML {
|
||||
thread := strings.Join(ids, "-")
|
||||
return renderComments(w, r, subject, thread, false)
|
||||
}
|
||||
|
||||
// RenderCommentsRO returns a read-only comment view.
|
||||
func RenderCommentsRO(w http.ResponseWriter, r *http.Request, ids ...string) template.HTML {
|
||||
thread := strings.Join(ids, "-")
|
||||
return renderComments(w, r, "", thread, true)
|
||||
}
|
||||
|
||||
// RenderComment renders the HTML partial for a single comment in the thread.
|
||||
func RenderComment(w http.ResponseWriter, r *http.Request, com models.Comment, originURL string, editable bool) template.HTML {
|
||||
var (
|
||||
html = bytes.NewBuffer([]byte{})
|
||||
v = responses.NewTemplateVars(html, r)
|
||||
editToken = getEditToken(w, r)
|
||||
)
|
||||
v.V["Comment"] = com
|
||||
v.V["Editable"] = editable && (com.EditToken == editToken || authentication.LoggedIn(r))
|
||||
v.V["OriginURL"] = originURL
|
||||
responses.PartialTemplate(html, r, "_builtin/comments/entry.partial.gohtml", v)
|
||||
return template.HTML(html.String())
|
||||
}
|
||||
|
||||
// RenderCommentForm renders the comment entry form HTML onto a page.
|
||||
func RenderCommentForm(r *http.Request, com models.Comment, subject, threadID, originURL string) template.HTML {
|
||||
var (
|
||||
html = bytes.NewBuffer([]byte{})
|
||||
v = responses.NewTemplateVars(html, r)
|
||||
)
|
||||
v.V["Comment"] = com
|
||||
v.V["Subject"] = subject
|
||||
v.V["ThreadID"] = threadID
|
||||
v.V["OriginURL"] = originURL
|
||||
responses.PartialTemplate(html, r, "_builtin/comments/form.partial.gohtml", v)
|
||||
return template.HTML(html.String())
|
||||
}
|
||||
|
||||
// renderComments is the internal logic for both RenderComments and RenderCommentsRO.
|
||||
func renderComments(w http.ResponseWriter, r *http.Request, subject string, thread string, readonly bool) template.HTML {
|
||||
var (
|
||||
html = bytes.NewBuffer([]byte{})
|
||||
v = responses.NewTemplateVars(w, r)
|
||||
ses = session.Get(r)
|
||||
)
|
||||
|
||||
comments, err := models.Comments.GetThread(thread)
|
||||
if err != nil {
|
||||
return template.HTML(fmt.Sprintf("[comment error: %s]", err))
|
||||
}
|
||||
|
||||
// Load their cached name and email from any previous comments the user posted.
|
||||
name, _ := ses.Values["c.name"].(string)
|
||||
email, _ := ses.Values["c.email"].(string)
|
||||
editToken, _ := ses.Values["c.token"].(string)
|
||||
|
||||
// Logged in? Populate defaults from the user info.
|
||||
if currentUser, err := authentication.CurrentUser(r); err == nil {
|
||||
name = currentUser.Name
|
||||
email = currentUser.Email
|
||||
}
|
||||
|
||||
// v.V["posts"] = posts.Posts
|
||||
v.V["Readonly"] = readonly
|
||||
v.V["Subject"] = subject
|
||||
v.V["ThreadID"] = thread
|
||||
v.V["Comments"] = comments
|
||||
v.V["OriginURL"] = r.URL.Path
|
||||
v.V["NewComment"] = models.Comment{
|
||||
Name: name,
|
||||
Email: email,
|
||||
EditToken: editToken,
|
||||
}
|
||||
responses.PartialTemplate(html, r, "_builtin/comments/comments.partial.gohtml", v)
|
||||
return template.HTML(html.String())
|
||||
}
|
||||
|
||||
// PostComment handles all of the top-level blog index routes:
|
||||
func PostComment(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
v = responses.NewTemplateVars(w, r)
|
||||
editToken = getEditToken(w, r)
|
||||
comment = models.Comments.New()
|
||||
ses = session.Get(r)
|
||||
editing bool // true if editing an existing comment
|
||||
)
|
||||
|
||||
// Check if the user is logged in.
|
||||
var loggedIn bool
|
||||
currentUser, err := authentication.CurrentUser(r)
|
||||
loggedIn = err == nil
|
||||
|
||||
// Get form parameters.
|
||||
form, _ := forms.Parse(r)
|
||||
v.V["Subject"] = form.Get("subject")
|
||||
v.V["ThreadID"] = form.Get("thread")
|
||||
v.V["OriginURL"] = form.Get("origin")
|
||||
|
||||
// Are they editing an existing post ID?
|
||||
if id := form.GetInt("id"); id > 0 {
|
||||
// Load the comment from DB.
|
||||
com, err := models.Comments.Load(id)
|
||||
if err != nil {
|
||||
responses.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the user's EditToken matches this comment.
|
||||
if editToken != com.EditToken && !loggedIn {
|
||||
responses.Forbidden(w, r, "You do not have permission to edit that comment.")
|
||||
return
|
||||
}
|
||||
|
||||
editing = true
|
||||
comment = com
|
||||
}
|
||||
|
||||
comment.EditToken = editToken
|
||||
|
||||
for {
|
||||
// Validate form parameters.
|
||||
val := form.Validator()
|
||||
val.Require("body")
|
||||
if !form.GetBool("editing") {
|
||||
comment.ParseForm(form)
|
||||
}
|
||||
|
||||
if val.HasErrors() {
|
||||
v.ValidationError = val.ErrorMap()
|
||||
break
|
||||
}
|
||||
|
||||
// Cache their name and email in their session, for future requests.
|
||||
ses.Values["c.email"] = form.Get("email")
|
||||
ses.Values["c.name"] = form.Get("name")
|
||||
ses.Save(r, w)
|
||||
|
||||
switch form.Get("submit") {
|
||||
case "delete":
|
||||
v.V["deleting"] = true
|
||||
case "confirm-delete":
|
||||
// Delete the comment.
|
||||
err := comment.Delete()
|
||||
if err != nil {
|
||||
session.Flash(w, r, "Error deleting the comment: %s", err)
|
||||
} else {
|
||||
session.Flash(w, r, "Comment has been deleted!")
|
||||
}
|
||||
responses.Redirect(w, r, form.Get("origin"))
|
||||
return
|
||||
case "preview":
|
||||
v.V["preview"] = comment.HTML()
|
||||
case "post":
|
||||
// If we're logged in, tag our user ID with this post.
|
||||
if loggedIn && !editing {
|
||||
comment.UserID = currentUser.ID
|
||||
}
|
||||
|
||||
// Post their comment.
|
||||
err := comment.Save()
|
||||
if err != nil {
|
||||
session.Flash(w, r, "Error posting comment: %s", err)
|
||||
responses.Redirect(w, r, form.Get("origin"))
|
||||
return
|
||||
}
|
||||
|
||||
// Notify site admins and subscribers by email.
|
||||
go mail.NotifyComment(v.V["Subject"].(string), v.V["OriginURL"].(string), comment)
|
||||
|
||||
session.Flash(w, r, "Your comment has been added!")
|
||||
responses.Redirect(w, r, form.Get("origin"))
|
||||
return
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
v.V["NewComment"] = comment
|
||||
responses.RenderTemplate(w, r, "_builtin/comments/preview.gohtml", v)
|
||||
}
|
||||
|
||||
// CommentQuickDelete handles quick-delete links to remove spam comments.
|
||||
func CommentQuickDelete(w http.ResponseWriter, r *http.Request) {
|
||||
// Query parameters.
|
||||
var (
|
||||
deleteToken = r.URL.Query().Get("d")
|
||||
nextURL = r.URL.Query().Get("next")
|
||||
)
|
||||
|
||||
// Look up the comment by thread and quick-delete token.
|
||||
comment, err := models.Comments.LoadByDeleteToken(deleteToken)
|
||||
if err != nil {
|
||||
responses.Forbidden(w, r, "Comment by Delete Token not found.")
|
||||
return
|
||||
}
|
||||
|
||||
comment.Delete()
|
||||
|
||||
session.Flash(w, r, "Comment has been quick-deleted!")
|
||||
|
||||
if nextURL == "" {
|
||||
nextURL = "/"
|
||||
}
|
||||
responses.Redirect(w, r, nextURL)
|
||||
}
|
||||
|
||||
// getEditToken gets the edit token from the user's session.
|
||||
func getEditToken(w http.ResponseWriter, r *http.Request) string {
|
||||
ses := session.Get(r)
|
||||
if token, ok := ses.Values["c.token"].(string); ok && len(token) > 0 {
|
||||
return token
|
||||
}
|
||||
|
||||
token := uuid.NewV4().String()
|
||||
ses.Values["c.token"] = token
|
||||
ses.Save(r, w)
|
||||
return token
|
||||
}
|
160
pkg/mail/mail.go
Normal file
160
pkg/mail/mail.go
Normal file
|
@ -0,0 +1,160 @@
|
|||
package mail
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.kirsle.net/apps/gophertype/pkg/console"
|
||||
"git.kirsle.net/apps/gophertype/pkg/markdown"
|
||||
"git.kirsle.net/apps/gophertype/pkg/models"
|
||||
"git.kirsle.net/apps/gophertype/pkg/responses"
|
||||
"git.kirsle.net/apps/gophertype/pkg/settings"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
gomail "gopkg.in/gomail.v2"
|
||||
)
|
||||
|
||||
// Email configuration.
|
||||
type Email struct {
|
||||
To string
|
||||
ReplyTo string
|
||||
Admin bool /* admin view of the email */
|
||||
Subject string
|
||||
UnsubscribeURL string
|
||||
Data map[string]interface{}
|
||||
|
||||
Template string
|
||||
}
|
||||
|
||||
// SendEmail sends an email.
|
||||
func SendEmail(email Email) {
|
||||
s := settings.Current
|
||||
|
||||
// Suppress sending any mail when no mail settings are configured, but go
|
||||
// through the motions -- great for local dev.
|
||||
var doNotMail bool
|
||||
if !s.MailEnabled || s.MailHost == "" || s.MailPort == 0 || s.MailSender == "" {
|
||||
console.Info("Suppressing email: not completely configured")
|
||||
doNotMail = true
|
||||
}
|
||||
|
||||
// Resolve the template.
|
||||
tmpl, err := responses.GetFile(email.Template)
|
||||
if err != nil {
|
||||
console.Error("SendEmail: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Render the template to HTML.
|
||||
var html bytes.Buffer
|
||||
t := template.New(email.Template)
|
||||
t, err = t.Parse(string(tmpl))
|
||||
if err != nil {
|
||||
console.Error("SendEmail: template parsing error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Execute the template.
|
||||
err = t.ExecuteTemplate(&html, email.Template, email)
|
||||
if err != nil {
|
||||
console.Error("SendEmail: template execution error: %s", err.Error())
|
||||
}
|
||||
|
||||
// Condense the body down to plain text, lazily. Who even has a plain text
|
||||
// email client anymore?
|
||||
rawLines := strings.Split(
|
||||
bluemonday.StrictPolicy().Sanitize(html.String()),
|
||||
"\n",
|
||||
)
|
||||
var lines []string
|
||||
for _, line := range rawLines {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
plaintext := strings.Join(lines, "\n\n")
|
||||
|
||||
// If we're not actually going to send the mail, this is a good place to stop.
|
||||
if doNotMail {
|
||||
console.Info("Not going to send an email.")
|
||||
console.Debug("The message was going to be:\n%s", plaintext)
|
||||
return
|
||||
}
|
||||
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", fmt.Sprintf("%s <%s>", s.Title, s.MailSender))
|
||||
m.SetHeader("To", email.To)
|
||||
if email.ReplyTo != "" {
|
||||
m.SetHeader("Reply-To", email.ReplyTo)
|
||||
}
|
||||
m.SetHeader("Subject", email.Subject)
|
||||
m.SetBody("text/plain", plaintext)
|
||||
m.AddAlternative("text/html", html.String())
|
||||
|
||||
d := gomail.NewDialer(s.MailHost, s.MailPort, s.MailUsername, s.MailPassword)
|
||||
|
||||
console.Info("SendEmail: %s (%s) to %s", email.Subject, email.Template, email.To)
|
||||
if err := d.DialAndSend(m); err != nil {
|
||||
console.Error("SendEmail: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyComment sends notification emails about comments.
|
||||
func NotifyComment(subject string, originURL string, c models.Comment) {
|
||||
s := settings.Current
|
||||
if s.BaseURL == "" {
|
||||
console.Error("Can't send comment notification because the site URL is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the email payload.
|
||||
email := Email{
|
||||
Template: "_builtin/email/comment.gohtml",
|
||||
Subject: "Comment Added: " + subject,
|
||||
Data: map[string]interface{}{
|
||||
"Name": c.Name,
|
||||
"Subject": subject,
|
||||
"Body": template.HTML(markdown.RenderMarkdown(c.Body)),
|
||||
"URL": strings.Trim(s.BaseURL, "/") + originURL,
|
||||
"QuickDelete": fmt.Sprintf("%s/comments/quick-delete?d=%s&next=%s",
|
||||
strings.Trim(s.BaseURL, "/"),
|
||||
url.QueryEscape(c.DeleteToken),
|
||||
url.QueryEscape(strings.Trim(s.BaseURL, "/")+originURL),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
// Email the site admins.
|
||||
if adminEmails, err := models.ListAdminEmails(); err == nil {
|
||||
email.To = strings.Join(adminEmails, ", ")
|
||||
email.Admin = true
|
||||
console.Info("Mail site admin '%s' about comment notification on '%s'", email.To, c.Thread)
|
||||
SendEmail(email)
|
||||
}
|
||||
|
||||
// // Email the subscribers.
|
||||
// email.Admin = false
|
||||
// m := comments.LoadMailingList()
|
||||
// for _, to := range m.List(c.ThreadID) {
|
||||
// if to == c.Email {
|
||||
// continue // don't email yourself
|
||||
// }
|
||||
// email.To = to
|
||||
// email.UnsubscribeURL = fmt.Sprintf("%s/comments/subscription?t=%s&e=%s",
|
||||
// strings.Trim(s.Site.URL, "/"),
|
||||
// url.QueryEscape(c.ThreadID),
|
||||
// url.QueryEscape(to),
|
||||
// )
|
||||
// console.Info("Mail subscriber '%s' about comment notification on '%s'", email.To, c.ThreadID)
|
||||
// SendEmail(email)
|
||||
// }
|
||||
}
|
||||
|
||||
// ParseAddress parses an email address.
|
||||
func ParseAddress(addr string) (*mail.Address, error) {
|
||||
return mail.ParseAddress(addr)
|
||||
}
|
139
pkg/models/comments.go
Normal file
139
pkg/models/comments.go
Normal file
|
@ -0,0 +1,139 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/mail"
|
||||
|
||||
"git.kirsle.net/apps/gophertype/pkg/console"
|
||||
"git.kirsle.net/apps/gophertype/pkg/markdown"
|
||||
"github.com/albrow/forms"
|
||||
"github.com/jinzhu/gorm"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
type commentMan struct{}
|
||||
|
||||
// Comments is a singleton manager class for Comment model access.
|
||||
var Comments = commentMan{}
|
||||
|
||||
// Comment model.
|
||||
type Comment struct {
|
||||
gorm.Model
|
||||
|
||||
Thread string `gorm:"index"` // name of comment thread
|
||||
UserID uint // foreign key to User.ID
|
||||
Name string
|
||||
Email string
|
||||
Avatar string
|
||||
Body string
|
||||
EditToken string // So users can edit their own recent comments.
|
||||
DeleteToken string `gorm:"unique_index"` // Quick-delete token for spam.
|
||||
|
||||
User User `gorm:"foreign_key:UserID"`
|
||||
}
|
||||
|
||||
// New creates a new Comment model.
|
||||
func (m commentMan) New() Comment {
|
||||
return Comment{
|
||||
DeleteToken: uuid.NewV4().String(),
|
||||
}
|
||||
}
|
||||
|
||||
// Load a comment by ID.
|
||||
func (m commentMan) Load(id int) (Comment, error) {
|
||||
var com Comment
|
||||
r := DB.Preload("User").First(&com, id)
|
||||
return com, r.Error
|
||||
}
|
||||
|
||||
// LoadByDeleteToken loads a comment by its DeleteToken.
|
||||
func (m commentMan) LoadByDeleteToken(token string) (Comment, error) {
|
||||
var com Comment
|
||||
r := DB.Preload("User").Where("delete_token = ?", token).First(&com)
|
||||
return com, r.Error
|
||||
}
|
||||
|
||||
// GetIndex returns the index page of blog posts.
|
||||
func (m commentMan) GetThread(thread string) ([]Comment, error) {
|
||||
var coms []Comment
|
||||
r := DB.Debug().Preload("User").
|
||||
Where("thread = ?", thread).
|
||||
Order("created_at asc").
|
||||
Find(&coms)
|
||||
return coms, r.Error
|
||||
}
|
||||
|
||||
// HTML returns the comment's body as rendered HTML code.
|
||||
func (c Comment) HTML() template.HTML {
|
||||
return template.HTML(markdown.RenderMarkdown(c.Body))
|
||||
}
|
||||
|
||||
// Save a comment.
|
||||
func (c *Comment) Save() error {
|
||||
// Ensure the delete token is unique!
|
||||
{
|
||||
if exist, err := Comments.LoadByDeleteToken(c.DeleteToken); err != nil && exist.ID != c.ID {
|
||||
console.Debug("Comment.Save: delete token is not unique, trying to resolve")
|
||||
var resolved bool
|
||||
for i := 2; i <= 100; i++ {
|
||||
token := uuid.NewV4().String()
|
||||
_, err = Comments.LoadByDeleteToken(token)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
c.DeleteToken = token
|
||||
resolved = true
|
||||
break
|
||||
}
|
||||
|
||||
if !resolved {
|
||||
return fmt.Errorf("failed to generate a unique delete token after 100 attempts")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.Info("Save comment: %+v", c)
|
||||
|
||||
// Save the post.
|
||||
if DB.NewRecord(c) {
|
||||
console.Warn("NEw Record!")
|
||||
return DB.Create(&c).Error
|
||||
}
|
||||
|
||||
return DB.Save(&c).Error
|
||||
}
|
||||
|
||||
// Delete a comment.
|
||||
func (c Comment) Delete() error {
|
||||
return DB.Delete(&c).Error
|
||||
}
|
||||
|
||||
// ParseForm populates a Post from an HTTP form.
|
||||
func (c *Comment) ParseForm(form *forms.Data) {
|
||||
c.Thread = form.Get("thread")
|
||||
c.Name = form.Get("name")
|
||||
c.Email = form.Get("email")
|
||||
c.Body = form.Get("body")
|
||||
c.LoadAvatar()
|
||||
}
|
||||
|
||||
// LoadAvatar calculates the user's avatar for the comment.
|
||||
func (c *Comment) LoadAvatar() {
|
||||
// MD5 hash the email address for Gravatar.
|
||||
if _, err := mail.ParseAddress(c.Email); err == nil {
|
||||
h := md5.New()
|
||||
io.WriteString(h, c.Email)
|
||||
hash := fmt.Sprintf("%x", h.Sum(nil))
|
||||
c.Avatar = fmt.Sprintf(
|
||||
"//www.gravatar.com/avatar/%s?s=96",
|
||||
hash,
|
||||
)
|
||||
} else {
|
||||
// Default gravatar.
|
||||
c.Avatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000"
|
||||
}
|
||||
}
|
|
@ -11,4 +11,5 @@ func UseDB(db *gorm.DB) {
|
|||
DB.AutoMigrate(&User{})
|
||||
DB.AutoMigrate(&Post{})
|
||||
DB.AutoMigrate(&TaggedPost{})
|
||||
DB.AutoMigrate(&Comment{})
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"html/template"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -35,6 +36,9 @@ type Post struct {
|
|||
EnableComments bool
|
||||
Tags []TaggedPost
|
||||
Author User `gorm:"foreign_key:UserID"`
|
||||
|
||||
// Private fields not in DB.
|
||||
CommentCount int `gorm:"-"`
|
||||
}
|
||||
|
||||
// PagedPosts holds a paginated response of multiple posts.
|
||||
|
@ -106,6 +110,10 @@ func (m postMan) GetIndexPosts(privacy string, page, perPage int) (PagedPosts, e
|
|||
pp.PreviousPage = pp.Page - 1
|
||||
}
|
||||
|
||||
if err := pp.CountComments(); err != nil {
|
||||
console.Error("PagedPosts.CountComments: %s", err)
|
||||
}
|
||||
|
||||
return pp, r.Error
|
||||
}
|
||||
|
||||
|
@ -157,9 +165,70 @@ func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPos
|
|||
pp.PreviousPage = pp.Page - 1
|
||||
}
|
||||
|
||||
if err := pp.CountComments(); err != nil {
|
||||
console.Error("PagedPosts.CountComments: %s", err)
|
||||
}
|
||||
|
||||
return pp, r.Error
|
||||
}
|
||||
|
||||
// CountComments gets comment counts for one or more posts.
|
||||
// Returns a map[uint]int mapping post ID to comment count.
|
||||
func (m postMan) CountComments(posts ...Post) (map[uint]int, error) {
|
||||
var result = map[uint]int{}
|
||||
|
||||
// Create the comment thread IDs.
|
||||
var threadIDs = make([]string, len(posts))
|
||||
for i, post := range posts {
|
||||
threadIDs[i] = fmt.Sprintf("post-%d", post.ID)
|
||||
}
|
||||
|
||||
// Query comment counts for each thread.
|
||||
if len(threadIDs) > 0 {
|
||||
rows, err := DB.Table("comments").
|
||||
Select("thread, count(*) as count").
|
||||
Group("thread").
|
||||
Rows()
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var thread string
|
||||
var count int
|
||||
if err := rows.Scan(&thread, &count); err != nil {
|
||||
console.Error("CountComments: rows.Scan: %s", err)
|
||||
}
|
||||
|
||||
postID, err := strconv.Atoi(strings.TrimPrefix(thread, "post-"))
|
||||
if err != nil {
|
||||
console.Warn("CountComments: strconv.Atoi(%s): %s", thread, err)
|
||||
}
|
||||
result[uint(postID)] = count
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// CountComments on the posts in a PagedPosts list.
|
||||
func (pp *PagedPosts) CountComments() error {
|
||||
counts, err := Posts.CountComments(pp.Posts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
console.Info("counts: %+v", counts)
|
||||
|
||||
for i, post := range pp.Posts {
|
||||
if count, ok := counts[post.ID]; ok {
|
||||
pp.Posts[i].CommentCount = count
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PreviewHTML returns the post's body as rendered HTML code, but only above
|
||||
// the <snip> tag for index views.
|
||||
func (p Post) PreviewHTML() template.HTML {
|
||||
|
|
|
@ -64,6 +64,21 @@ func GetUserByEmail(email string) (User, error) {
|
|||
return user, r.Error
|
||||
}
|
||||
|
||||
// ListAdminEmails returns the array of email addresses of all admin users.
|
||||
func ListAdminEmails() ([]string, error) {
|
||||
var (
|
||||
users []User
|
||||
emails []string
|
||||
)
|
||||
r := DB.Where("is_admin=true AND email IS NOT NULL").Find(&users)
|
||||
for _, user := range users {
|
||||
if len(user.Email) > 0 {
|
||||
emails = append(emails, user.Email)
|
||||
}
|
||||
}
|
||||
return emails, r.Error
|
||||
}
|
||||
|
||||
// SetPassword stores the hashed password for a user.
|
||||
func (u *User) SetPassword(password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost)
|
||||
|
|
|
@ -38,6 +38,7 @@ func NewTemplateVars(w io.Writer, r *http.Request) TemplateValues {
|
|||
}
|
||||
|
||||
if rw, ok := w.(http.ResponseWriter); ok {
|
||||
v.ResponseWriter = rw
|
||||
v.Flashes = session.GetFlashes(rw, r)
|
||||
}
|
||||
|
||||
|
@ -57,6 +58,7 @@ type TemplateValues struct {
|
|||
Request *http.Request
|
||||
RequestTime time.Time
|
||||
RequestDuration time.Duration
|
||||
ResponseWriter http.ResponseWriter
|
||||
FormValues url.Values
|
||||
Path string // request path
|
||||
TemplatePath string // file path of html template, like "_builtin/error.gohtml"
|
||||
|
|
|
@ -41,6 +41,14 @@
|
|||
{{ end }}
|
||||
</em></small>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<small class="text-muted"><em>
|
||||
<a href="/{{ $Post.Fragment }}#comments">{{ $Post.CommentCount }} comment{{ if ne $Post.CommentCount 1 }}s{{ end }}</a>
|
||||
|
|
||||
<a href="/{{ $Post.Fragment }}">Permalink</a>
|
||||
</em></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -43,6 +43,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{ $idStr := printf "%d" $Post.ID }}
|
||||
{{ if $Post.EnableComments }}
|
||||
{{ RenderComments .ResponseWriter .Request $Post.Title "post" $idStr }}
|
||||
{{ else }}
|
||||
{{ RenderCommentsRO .ResponseWriter .Request "post" $idStr }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .CurrentUser.IsAdmin }}
|
||||
<div class="alert alert-secondary">
|
||||
<strong>Admin:</strong>
|
||||
|
|
27
pvt-www/_builtin/comments/comments.partial.gohtml
Normal file
27
pvt-www/_builtin/comments/comments.partial.gohtml
Normal file
|
@ -0,0 +1,27 @@
|
|||
<hr>
|
||||
|
||||
<h1 id="comments">Comments</h1>
|
||||
|
||||
<p>
|
||||
There
|
||||
{{ if eq (len .V.Comments) 1 }}
|
||||
is one comment
|
||||
{{ else if eq (len .V.Comments) 0 }}
|
||||
are no comments
|
||||
{{ else }}
|
||||
are {{ len .V.Comments }} comments
|
||||
{{ end }}
|
||||
on this page.
|
||||
</p>
|
||||
|
||||
{{ range .V.Comments }}
|
||||
{{ RenderComment $.ResponseWriter $.Request . $.V.OriginURL true }}
|
||||
{{ end }}
|
||||
|
||||
<h3 id="add-comment">Add a Comment</h3>
|
||||
|
||||
{{ if .V.Readonly }}
|
||||
<em>Comments are disabled on this page.</em>
|
||||
{{ else }}
|
||||
{{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }}
|
||||
{{ end }}
|
55
pvt-www/_builtin/comments/entry.partial.gohtml
Normal file
55
pvt-www/_builtin/comments/entry.partial.gohtml
Normal file
|
@ -0,0 +1,55 @@
|
|||
{{ $C := .V.Comment }}
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="markdown col-12 col-lg-2 mb-1">
|
||||
<img src="{{ $C.Avatar }}"
|
||||
width="96"
|
||||
height="96"
|
||||
alt="Avatar image">
|
||||
</div>
|
||||
<div class="markdown col-12 col-lg-10">
|
||||
<div class="comment-meta">
|
||||
{{ if and $C.UserID $C.User }}
|
||||
<strong>{{ or $C.Name "Anonymous" }}</strong>
|
||||
{{ if $C.User.IsAdmin }}<span class="text-danger">(admin)</span>
|
||||
{{ else }}<span class="text-info">(logged in)</span>{{ end }}
|
||||
{{ else }}
|
||||
<strong>{{ or $C.Name "Anonymous" }}</strong>
|
||||
{{ end }}
|
||||
|
||||
posted on {{ $C.CreatedAt.Format "January 2, 2006 @ 15:04 MST" }}
|
||||
|
||||
{{ if $C.UpdatedAt.After $C.CreatedAt }}
|
||||
<span title="{{ $C.UpdatedAt.Format "Jan 2 2006 @ 15:04:05 MST" }}">
|
||||
(updated {{ $C.UpdatedAt.Format "1/2/06 15:04 MST"}})
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
{{ $C.HTML }}
|
||||
|
||||
{{ if and $C.ID .V.Editable }}
|
||||
<form action="/comments" method="POST">
|
||||
{{ CSRF }}
|
||||
<input type="hidden" name="id" value="{{ $C.ID }}">
|
||||
<input type="hidden" name="thread" value="{{ $C.Thread }}">
|
||||
<input type="hidden" name="subject" value="(editing)">
|
||||
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
|
||||
<input type="hidden" name="body" value="{{ $C.Body }}">
|
||||
<input type="hidden" name="editing" value="true">
|
||||
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="preview"
|
||||
class="btn btn-sm btn-primary">edit</button>
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="delete"
|
||||
class="btn btn-sm btn-danger">delete</button>
|
||||
</form>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
80
pvt-www/_builtin/comments/form.partial.gohtml
Normal file
80
pvt-www/_builtin/comments/form.partial.gohtml
Normal file
|
@ -0,0 +1,80 @@
|
|||
{{ $NC := .V.Comment }}
|
||||
<form name="comments" action="/comments" method="POST">
|
||||
{{ CSRF }}
|
||||
<input type="hidden" name="id" value="{{ $NC.ID }}">
|
||||
<input type="hidden" name="subject" value="{{ .V.Subject }}">
|
||||
<input type="hidden" name="thread" value="{{ .V.ThreadID }}">
|
||||
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
|
||||
|
||||
<div class="form-group row">
|
||||
<label for="name" class="col-12 col-lg-2 col-form-label">Your name:</label>
|
||||
<div class="col-12 col-lg-10">
|
||||
<input type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="form-control"
|
||||
value="{{ $NC.Name }}"
|
||||
placeholder="Anonymous">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group row">
|
||||
<label for="email" class="col-12 col-lg-2 col-form-label">Your email:</label>
|
||||
<div class="col-12 col-lg-10">
|
||||
<input type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="form-control"
|
||||
aria-describedby="emailHelp"
|
||||
value="{{ $NC.Email }}"
|
||||
placeholder="(optional)">
|
||||
<small id="emailHelp" class="form-text text-muted">
|
||||
Optional; used for your <a href="https://en.gravatar.com/" target="_blank">Gravatar</a>.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-12">
|
||||
<label for="body">Message:</label>
|
||||
<textarea name="body"
|
||||
id="body"
|
||||
class="form-control"
|
||||
cols="80"
|
||||
rows="10"
|
||||
required="required"
|
||||
aria-describedby="bodyHelp">{{ $NC.Body }}</textarea>
|
||||
<small id="bodyHelp" class="form-text text-muted">
|
||||
You may format your comment using
|
||||
<a href="https://github.github.com/gfm/" target="_blank">GitHub Flavored Markdown</a>
|
||||
syntax.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row" style="display: none">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-header">Sanity Check</div>
|
||||
<div class="card-body">
|
||||
If you can see this, do not touch the following fields.
|
||||
|
||||
<input type="text" name="url" value="http://" class="form-control" placeholder="Website">
|
||||
<textarea name="comment" cols="80" rows="10" class="form-control" placeholder="Comment"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col">
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="preview"
|
||||
class="btn btn-primary">Preview</button>
|
||||
<button type="submit"
|
||||
name="submit"
|
||||
value="post"
|
||||
class="btn btn-danger">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
44
pvt-www/_builtin/comments/preview.gohtml
Normal file
44
pvt-www/_builtin/comments/preview.gohtml
Normal file
|
@ -0,0 +1,44 @@
|
|||
{{ define "title" }}Preview Comment{{ end }}
|
||||
{{ define "content" }}
|
||||
|
||||
{{ if .V.deleting }}
|
||||
<h1>Delete Comment?</h1>
|
||||
{{ else }}
|
||||
<h1>Preview Comment</h1>
|
||||
{{ end }}
|
||||
|
||||
{{ RenderComment .ResponseWriter .Request .V.NewComment .V.OriginURL false }}
|
||||
|
||||
{{ if .V.deleting }}
|
||||
<form action="/comments" method="POST">
|
||||
{{ CSRF }}
|
||||
<input type="hidden" name="id" value="{{ .V.NewComment.ID }}">
|
||||
<input type="hidden" name="origin" value="{{ .V.OriginURL }}">
|
||||
<input type="hidden" name="body" value="{{ .V.NewComment.Body }}">
|
||||
|
||||
<p>
|
||||
Are you sure you want to delete this comment?
|
||||
</p>
|
||||
|
||||
<button type="submit"
|
||||
class="btn btn-primary mr-2"
|
||||
name="submit"
|
||||
value="confirm-delete">
|
||||
Yes, Delete
|
||||
</button>
|
||||
<a href="{{ .V.OriginURL }}"
|
||||
class="btn btn-secondary">
|
||||
Cancel
|
||||
</a>
|
||||
</form>
|
||||
{{ else }}
|
||||
{{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }}
|
||||
{{ end }}
|
||||
|
||||
{{ if .V.OriginURL}}
|
||||
<div class="mt-4">
|
||||
(<a href="{{ .V.OriginURL }}">back to post</a>)
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
61
pvt-www/_builtin/email/comment.gohtml
Normal file
61
pvt-www/_builtin/email/comment.gohtml
Normal file
|
@ -0,0 +1,61 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
|
||||
<title>{{ .Subject }}</title>
|
||||
</head>
|
||||
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
|
||||
|
||||
<center>
|
||||
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
|
||||
<tr>
|
||||
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
|
||||
<b>{{ .Subject }}</b>
|
||||
</font>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" bgcolor="#FEFEFE">
|
||||
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
||||
{{ if not .Admin }}
|
||||
Hello,<br><br>
|
||||
{{ end }}
|
||||
|
||||
{{ or .Data.Name "Anonymous" }} has left a comment on: {{ .Data.Subject }}
|
||||
<br><br>
|
||||
|
||||
{{ .Data.Body }}
|
||||
<br><br>
|
||||
|
||||
<hr>
|
||||
|
||||
To view this comment, please go to <a href="{{ .Data.URL }}" target="_blank">{{ .Data.URL }}</a>.
|
||||
|
||||
{{ if .Admin }}
|
||||
<br><br>
|
||||
Was this comment spam? <a href="{{ .Data.QuickDelete }}" target="_blank">Delete it</a>.
|
||||
{{ end }}
|
||||
|
||||
{{ if .UnsubscribeURL }}
|
||||
<br><br>
|
||||
To unsubscribe from this comment thread, visit <a href="{{ .UnsubscribeURL }}" target="_blank">{{ .UnsubscribeURL }}</a>
|
||||
{{ end }}
|
||||
</font>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" bgcolor="#C0C0C0">
|
||||
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
|
||||
This e-mail was automatically generated; do not reply to it.
|
||||
</font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
|
||||
</body>
|
||||
</html>
|
Loading…
Reference in New Issue
Block a user