Basic Commenting System Implemented
* List comments on a post * Add comments, with preview * Users can edit their own comments (EditToken) * Admin can edit all comments * Delete comments * Comment counts on main blog index pagesmaster
parent
fd1494cf75
commit
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/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470
|
||||||
github.com/urfave/negroni v1.0.0
|
github.com/urfave/negroni v1.0.0
|
||||||
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443
|
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 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-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
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/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/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=
|
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.
|
// Register blog global template functions.
|
||||||
responses.ExtraFuncs = template.FuncMap{
|
responses.ExtraFuncs = template.FuncMap{
|
||||||
"BlogIndex": controllers.PartialBlogIndex,
|
"BlogIndex": controllers.PartialBlogIndex,
|
||||||
|
"RenderComments": controllers.RenderComments,
|
||||||
|
"RenderCommentsRO": controllers.RenderCommentsRO,
|
||||||
|
"RenderComment": controllers.RenderComment,
|
||||||
|
"RenderCommentForm": controllers.RenderCommentForm,
|
||||||
}
|
}
|
||||||
|
|
||||||
return site
|
return site
|
||||||
|
|
|
@ -0,0 +1,229 @@
|
||||||
|
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/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
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
v := responses.NewTemplateVars(w, r)
|
||||||
|
responses.RenderTemplate(w, r, "_builtin/blog/view-post.gohtml", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
|
@ -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(&User{})
|
||||||
DB.AutoMigrate(&Post{})
|
DB.AutoMigrate(&Post{})
|
||||||
DB.AutoMigrate(&TaggedPost{})
|
DB.AutoMigrate(&TaggedPost{})
|
||||||
|
DB.AutoMigrate(&Comment{})
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -35,6 +36,9 @@ type Post struct {
|
||||||
EnableComments bool
|
EnableComments bool
|
||||||
Tags []TaggedPost
|
Tags []TaggedPost
|
||||||
Author User `gorm:"foreign_key:UserID"`
|
Author User `gorm:"foreign_key:UserID"`
|
||||||
|
|
||||||
|
// Private fields not in DB.
|
||||||
|
CommentCount int `gorm:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PagedPosts holds a paginated response of multiple posts.
|
// 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
|
pp.PreviousPage = pp.Page - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := pp.CountComments(); err != nil {
|
||||||
|
console.Error("PagedPosts.CountComments: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
return pp, r.Error
|
return pp, r.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -157,9 +165,70 @@ func (m postMan) GetPostsByTag(tag, privacy string, page, perPage int) (PagedPos
|
||||||
pp.PreviousPage = pp.Page - 1
|
pp.PreviousPage = pp.Page - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := pp.CountComments(); err != nil {
|
||||||
|
console.Error("PagedPosts.CountComments: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
return pp, r.Error
|
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
|
// PreviewHTML returns the post's body as rendered HTML code, but only above
|
||||||
// the <snip> tag for index views.
|
// the <snip> tag for index views.
|
||||||
func (p Post) PreviewHTML() template.HTML {
|
func (p Post) PreviewHTML() template.HTML {
|
||||||
|
|
|
@ -38,6 +38,7 @@ func NewTemplateVars(w io.Writer, r *http.Request) TemplateValues {
|
||||||
}
|
}
|
||||||
|
|
||||||
if rw, ok := w.(http.ResponseWriter); ok {
|
if rw, ok := w.(http.ResponseWriter); ok {
|
||||||
|
v.ResponseWriter = rw
|
||||||
v.Flashes = session.GetFlashes(rw, r)
|
v.Flashes = session.GetFlashes(rw, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,6 +58,7 @@ type TemplateValues struct {
|
||||||
Request *http.Request
|
Request *http.Request
|
||||||
RequestTime time.Time
|
RequestTime time.Time
|
||||||
RequestDuration time.Duration
|
RequestDuration time.Duration
|
||||||
|
ResponseWriter http.ResponseWriter
|
||||||
FormValues url.Values
|
FormValues url.Values
|
||||||
Path string // request path
|
Path string // request path
|
||||||
TemplatePath string // file path of html template, like "_builtin/error.gohtml"
|
TemplatePath string // file path of html template, like "_builtin/error.gohtml"
|
||||||
|
|
|
@ -41,6 +41,14 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</em></small>
|
</em></small>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -43,6 +43,13 @@
|
||||||
</div>
|
</div>
|
||||||
</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 }}
|
{{ if .CurrentUser.IsAdmin }}
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary">
|
||||||
<strong>Admin:</strong>
|
<strong>Admin:</strong>
|
||||||
|
|
|
@ -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 }}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 }}
|
Loading…
Reference in New Issue