From ceb42aa4d05120ab181afb6f7da0e2ce341143f1 Mon Sep 17 00:00:00 2001 From: Noah Date: Thu, 13 Feb 2020 22:03:01 -0800 Subject: [PATCH] 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 pages --- go.mod | 1 + go.sum | 1 + pkg/app.go | 6 +- pkg/controllers/comments.go | 229 ++++++++++++++++++ pkg/models/comments.go | 139 +++++++++++ pkg/models/models.go | 1 + pkg/models/posts.go | 69 ++++++ pkg/responses/template_vars.go | 2 + pvt-www/_builtin/blog/index.partial.gohtml | 8 + pvt-www/_builtin/blog/view-post.gohtml | 7 + .../_builtin/comments/comments.partial.gohtml | 27 +++ .../_builtin/comments/entry.partial.gohtml | 55 +++++ pvt-www/_builtin/comments/form.partial.gohtml | 80 ++++++ pvt-www/_builtin/comments/preview.gohtml | 44 ++++ 14 files changed, 668 insertions(+), 1 deletion(-) create mode 100644 pkg/controllers/comments.go create mode 100644 pkg/models/comments.go create mode 100644 pvt-www/_builtin/comments/comments.partial.gohtml create mode 100644 pvt-www/_builtin/comments/entry.partial.gohtml create mode 100644 pvt-www/_builtin/comments/form.partial.gohtml create mode 100644 pvt-www/_builtin/comments/preview.gohtml diff --git a/go.mod b/go.mod index 6f43051..cc021dc 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 06572c5..5b0ce74 100644 --- a/go.sum +++ b/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= diff --git a/pkg/app.go b/pkg/app.go index ffcfde8..704ba00 100644 --- a/pkg/app.go +++ b/pkg/app.go @@ -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 diff --git a/pkg/controllers/comments.go b/pkg/controllers/comments.go new file mode 100644 index 0000000..a2b9c65 --- /dev/null +++ b/pkg/controllers/comments.go @@ -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 +} diff --git a/pkg/models/comments.go b/pkg/models/comments.go new file mode 100644 index 0000000..e3d06b7 --- /dev/null +++ b/pkg/models/comments.go @@ -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" + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go index f749d22..32cef81 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -11,4 +11,5 @@ func UseDB(db *gorm.DB) { DB.AutoMigrate(&User{}) DB.AutoMigrate(&Post{}) DB.AutoMigrate(&TaggedPost{}) + DB.AutoMigrate(&Comment{}) } diff --git a/pkg/models/posts.go b/pkg/models/posts.go index 3497052..b5771cc 100644 --- a/pkg/models/posts.go +++ b/pkg/models/posts.go @@ -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 tag for index views. func (p Post) PreviewHTML() template.HTML { diff --git a/pkg/responses/template_vars.go b/pkg/responses/template_vars.go index 9c9f327..c665b17 100644 --- a/pkg/responses/template_vars.go +++ b/pkg/responses/template_vars.go @@ -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" diff --git a/pvt-www/_builtin/blog/index.partial.gohtml b/pvt-www/_builtin/blog/index.partial.gohtml index dd739ca..36bc814 100644 --- a/pvt-www/_builtin/blog/index.partial.gohtml +++ b/pvt-www/_builtin/blog/index.partial.gohtml @@ -41,6 +41,14 @@ {{ end }} + + diff --git a/pvt-www/_builtin/blog/view-post.gohtml b/pvt-www/_builtin/blog/view-post.gohtml index 2bfbdd0..f5c8917 100644 --- a/pvt-www/_builtin/blog/view-post.gohtml +++ b/pvt-www/_builtin/blog/view-post.gohtml @@ -43,6 +43,13 @@ +{{ $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 }}
Admin: diff --git a/pvt-www/_builtin/comments/comments.partial.gohtml b/pvt-www/_builtin/comments/comments.partial.gohtml new file mode 100644 index 0000000..8a867ef --- /dev/null +++ b/pvt-www/_builtin/comments/comments.partial.gohtml @@ -0,0 +1,27 @@ +
+ +

Comments

+ +

+ 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. +

+ +{{ range .V.Comments }} + {{ RenderComment $.ResponseWriter $.Request . $.V.OriginURL true }} +{{ end }} + +

Add a Comment

+ +{{ if .V.Readonly }} + Comments are disabled on this page. +{{ else }} + {{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }} +{{ end }} diff --git a/pvt-www/_builtin/comments/entry.partial.gohtml b/pvt-www/_builtin/comments/entry.partial.gohtml new file mode 100644 index 0000000..0023503 --- /dev/null +++ b/pvt-www/_builtin/comments/entry.partial.gohtml @@ -0,0 +1,55 @@ +{{ $C := .V.Comment }} +
+
+
+
+ Avatar image +
+
+
+ {{ if and $C.UserID $C.User }} + {{ or $C.Name "Anonymous" }} + {{ if $C.User.IsAdmin }}(admin) + {{ else }}(logged in){{ end }} + {{ else }} + {{ or $C.Name "Anonymous" }} + {{ end }} + + posted on {{ $C.CreatedAt.Format "January 2, 2006 @ 15:04 MST" }} + + {{ if $C.UpdatedAt.After $C.CreatedAt }} + + (updated {{ $C.UpdatedAt.Format "1/2/06 15:04 MST"}}) + + {{ end }} +
+ + {{ $C.HTML }} + + {{ if and $C.ID .V.Editable }} +
+ {{ CSRF }} + + + + + + + + + +
+ {{ end }} +
+
+
+
diff --git a/pvt-www/_builtin/comments/form.partial.gohtml b/pvt-www/_builtin/comments/form.partial.gohtml new file mode 100644 index 0000000..fb4124d --- /dev/null +++ b/pvt-www/_builtin/comments/form.partial.gohtml @@ -0,0 +1,80 @@ +{{ $NC := .V.Comment }} +
+ {{ CSRF }} + + + + + +
+ +
+ +
+
+
+ +
+ + + Optional; used for your Gravatar. + +
+
+ +
+
+ + + + You may format your comment using + GitHub Flavored Markdown + syntax. + +
+
+ + + +
+
+ + +
+
+
diff --git a/pvt-www/_builtin/comments/preview.gohtml b/pvt-www/_builtin/comments/preview.gohtml new file mode 100644 index 0000000..9606083 --- /dev/null +++ b/pvt-www/_builtin/comments/preview.gohtml @@ -0,0 +1,44 @@ +{{ define "title" }}Preview Comment{{ end }} +{{ define "content" }} + +{{ if .V.deleting }} +

Delete Comment?

+{{ else }} +

Preview Comment

+{{ end }} + +{{ RenderComment .ResponseWriter .Request .V.NewComment .V.OriginURL false }} + +{{ if .V.deleting }} +
+ {{ CSRF }} + + + + +

+ Are you sure you want to delete this comment? +

+ + + + Cancel + +
+{{ else }} + {{ RenderCommentForm .Request .V.NewComment .V.Subject .V.ThreadID .V.OriginURL }} +{{ end }} + +{{ if .V.OriginURL}} + +{{ end }} + +{{ end }}